From 51b324a198ab596c3df47199d3d4237c05b44603 Mon Sep 17 00:00:00 2001 From: winewill Date: Tue, 20 Oct 2026 13:47:18 +0800 Subject: [PATCH 01/46] perf(desktop): persist download and pnpm packaging caches --- .github/workflows/macos-package.yml | 2 ++ .github/workflows/windows-package.yml | 6 +++++ apps/desktop/packaging/README.i18n.yaml | 4 ++-- apps/desktop/packaging/README.md | 2 ++ apps/desktop/packaging/README.zh.md | 2 ++ .../desktop/scripts/desktop-build-paths.d.mts | 8 ++++--- apps/desktop/scripts/desktop-build-paths.mjs | 14 +++++++---- .../scripts/desktop-package-environment.mjs | 2 ++ apps/desktop/scripts/prepare-dsh.ts | 4 +++- .../desktop/tests/desktop-build-paths.spec.ts | 17 +++++++++++++- .../tests/desktop-package-environment.spec.ts | 5 ++++ scripts/macos-package-workflow.spec.ts | 7 ++++++ scripts/windows-package-workflow.spec.ts | 23 +++++++++++++++++++ 13 files changed, 84 insertions(+), 12 deletions(-) diff --git a/.github/workflows/macos-package.yml b/.github/workflows/macos-package.yml index b0c3164729..7e533fa323 100644 --- a/.github/workflows/macos-package.yml +++ b/.github/workflows/macos-package.yml @@ -62,6 +62,8 @@ jobs: PACKAGE_VERSION_MODE: ${{ inputs.version_mode }} DSH_DESKTOP_UPDATE_CHANNEL: ${{ inputs.update_channel }} DSH_TELEMETRY_DISABLED: '1' + DSH_DESKTOP_PACKAGE_CACHE_DIR: ${{ runner.tool_cache }}/dsh-desktop-package-cache + PNPM_CONFIG_STORE_DIR: ${{ runner.tool_cache }}/dsh-desktop-package-cache/workspace-pnpm-store DSH_PACKAGING_PRESET: ${{ inputs.preset }} PACKAGE_TEST_RELEASE_ID: ${{ inputs.test_release_id }} steps: diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index bc76eca87b..d2a00a7f3f 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -62,6 +62,7 @@ jobs: PACKAGE_VERSION_MODE: ${{ inputs.version_mode }} DSH_DESKTOP_UPDATE_CHANNEL: ${{ inputs.update_channel }} DSH_TELEMETRY_DISABLED: '1' + DSH_DESKTOP_PACKAGE_CACHE_DIR: ${{ runner.tool_cache }}/dsh-desktop-package-cache DSH_PACKAGING_PRESET: ${{ inputs.preset }} PACKAGE_TEST_RELEASE_ID: ${{ inputs.test_release_id }} PACKAGE_SIGNING: ${{ inputs.signing }} @@ -107,6 +108,11 @@ jobs: - uses: actions/setup-node@v7.0.0 with: node-version: '24' + - name: Configure persistent pnpm store + # PNPM_HOME belongs to this run; keep downloaded packages outside its temporary directory. + run: | + $storeRoot = Join-Path ([IO.Path]::GetPathRoot($env:GITHUB_WORKSPACE)) '.pnpm-store' + "PNPM_CONFIG_STORE_DIR=$storeRoot" >> $env:GITHUB_ENV - name: Install locked dependencies run: | pnpm install --frozen-lockfile diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index 96c2ee0cf0..aac571f1f2 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -15,8 +15,8 @@ en: a6203dc2e690c7e1 zh: 07b516549b8e3328 /desktop-packaging/choose-a-build-path: - en: 17c6bcdefa05a8f8 - zh: 4ee18f832276144d + en: 447e66fb59b03fb1 + zh: a87d440407b4bde3 /desktop-packaging/release-versions: en: 3f38ab26ee195b39 zh: f00eddafa8f3b9ca diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index a77d260591..0255960a2c 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -32,6 +32,8 @@ Build Desktop installers locally or through Windows or macOS Actions, choose dep Run local commands from the repository root after installing locked dependencies. Use platform-specific commands for a local build, or platform Actions for the registered self-hosted runner. Choose test or production deployment independently from signed or unsigned mode. Read [Windows signing and runner operations](../windows-signing/README.md#trust-and-isolation) before any Windows signed build or shared-runner use. Installation is separate from packaging; [publication](../publishing/README.md) is a further explicit operation. +Set `DSH_DESKTOP_PACKAGE_CACHE_DIR` to an absolute build-cache directory to retain verified downloads and the pnpm content store across checkouts. Both packaging workflows use `runner.tool_cache/dsh-desktop-package-cache`; local commands default to `.desktop-build`. Runtime preparation still creates a fresh installation and lockfile, verifies stored package integrity, and copies files before signing. Cleanup removes temporary installations, not these caches. Operators manage cache disk usage; no automatic eviction runs. + ## Release versions diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index 54e9d740f8..de9d86eb20 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -32,6 +32,8 @@ description: "在本地或 Windows/macOS Actions 中生成 Desktop 安装包 安装锁定依赖后,从仓库根目录运行本地命令。本地构建使用平台命令,已注册的自托管 runner 使用对应平台的 Actions。test/production 部署与 signed/unsigned 模式分别选择。任何 Windows 签名构建或共享 runner 使用前,先阅读 [Windows 签名与 runner 运维](../windows-signing/README.zh.md#trust-and-isolation)。安装与打包分开进行;[发布](../publishing/README.zh.md)还需要单独的显式操作。 +将 `DSH_DESKTOP_PACKAGE_CACHE_DIR` 设为绝对构建缓存目录,即可跨 checkout 保留经过校验的下载文件和 pnpm 内容存储。两个打包工作流使用 `runner.tool_cache/dsh-desktop-package-cache`;本地命令默认使用 `.desktop-build`。runtime 准备仍创建全新安装目录和锁文件、校验缓存包的完整性,并在签名前复制文件。清理只删除临时安装,不删除这些缓存。缓存磁盘用量由操作者管理,不自动淘汰。 + ## 发布版本 diff --git a/apps/desktop/scripts/desktop-build-paths.d.mts b/apps/desktop/scripts/desktop-build-paths.d.mts index c7b8173ca1..c069dda3f7 100644 --- a/apps/desktop/scripts/desktop-build-paths.d.mts +++ b/apps/desktop/scripts/desktop-build-paths.d.mts @@ -1,6 +1,6 @@ import type { DesktopAutoUpdateTarget } from './desktop-auto-update-environment.mjs' -/** Mutable target directories plus the shared immutable download cache. */ +/** Mutable target directories plus shared download and package stores. */ export interface DesktopTargetBuildPaths { readonly root: string readonly artifacts: string @@ -15,6 +15,7 @@ export interface DesktopTargetBuildPaths { readonly packedVendor: string readonly packedLandlock: string readonly downloads: string + readonly pnpmStore: string } /** @@ -33,9 +34,10 @@ export function resolveDesktopBuildTarget( /** * Return the mutable preparation and artifact directories owned by one release target. * @param target - Supported Desktop target name. - * @returns Target paths plus the shared immutable download cache. + * @param environment - Build cache override; defaults to the current process. + * @returns Target paths plus shared download and package stores. */ -export function desktopTargetBuildPaths(target: DesktopAutoUpdateTarget): DesktopTargetBuildPaths +export function desktopTargetBuildPaths(target: DesktopAutoUpdateTarget, environment?: NodeJS.ProcessEnv): DesktopTargetBuildPaths /** * Return the platform and architecture of the payload one release target prepares. diff --git a/apps/desktop/scripts/desktop-build-paths.mjs b/apps/desktop/scripts/desktop-build-paths.mjs index e577b1d3fe..68a0a330a8 100644 --- a/apps/desktop/scripts/desktop-build-paths.mjs +++ b/apps/desktop/scripts/desktop-build-paths.mjs @@ -1,6 +1,6 @@ /** Resolve build-owned Desktop paths without sharing mutable state across release targets. */ -import { join, resolve } from 'node:path' +import { isAbsolute, join, resolve } from 'node:path' const APP_ROOT = resolve(import.meta.dirname, '..') const BUILD_ROOT = join(APP_ROOT, '.desktop-build') @@ -38,10 +38,13 @@ function assertSupportedTarget(target) { /** * Return the mutable preparation and artifact directories owned by one release target. * @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target - Supported Desktop target name. - * @returns {{ root: string, artifacts: string, unsignedArtifacts: string, unnotarizedArtifacts: string, runtime: string, packageSet: string, dsh: string, dshPnpm: string, electron: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string }} Target paths plus the shared immutable download cache. + * @param {NodeJS.ProcessEnv} environment Build cache override; defaults to the current process. + * @returns {{ root: string, artifacts: string, unsignedArtifacts: string, unnotarizedArtifacts: string, runtime: string, packageSet: string, dsh: string, dshPnpm: string, electron: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string, pnpmStore: string }} Target paths plus shared download and package stores. */ -export function desktopTargetBuildPaths(target) { +export function desktopTargetBuildPaths(target, environment = process.env) { assertSupportedTarget(target) + const cache = environment.DSH_DESKTOP_PACKAGE_CACHE_DIR ?? BUILD_ROOT + if (!isAbsolute(cache)) throw new Error('desktop build paths: DSH_DESKTOP_PACKAGE_CACHE_DIR must be absolute') const root = join(BUILD_ROOT, 'targets', target) const packed = join(root, 'packed') return { @@ -57,7 +60,8 @@ export function desktopTargetBuildPaths(target) { packedDsh: join(packed, 'dsh'), packedVendor: join(packed, 'vendor'), packedLandlock: join(packed, 'landlock'), - downloads: join(BUILD_ROOT, 'downloads'), + downloads: join(cache, 'downloads'), + pnpmStore: join(cache, 'pnpm-store'), } } @@ -87,7 +91,7 @@ export function resolveDesktopTargetBuildPaths( hostPlatform = process.platform, hostArch = process.arch, ) { - return desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch)) + return desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch), env) } /** diff --git a/apps/desktop/scripts/desktop-package-environment.mjs b/apps/desktop/scripts/desktop-package-environment.mjs index 32f58ce874..81e1bf5274 100644 --- a/apps/desktop/scripts/desktop-package-environment.mjs +++ b/apps/desktop/scripts/desktop-package-environment.mjs @@ -12,6 +12,7 @@ import { validateMacOSSignatureCacheDirectory } from './macos-signature-cache-di import { resolveMacOSPackageSettings } from './macos-package-settings.mjs' import { resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs' import { resolveWindowsPackageSettings } from './windows-package-settings.mjs' +import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' const APP_ROOT = fileURLToPath(new URL('..', import.meta.url)) const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_RELEASE_ID|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u @@ -134,6 +135,7 @@ function requireReadableFile(environment, name) { * @returns {void} */ export function validateDesktopPackageEnvironment(environment, target, options = {}) { + resolveDesktopTargetBuildPaths({ ...environment, DSH_DESKTOP_TARGET_PLATFORM: target.platform, DSH_DESKTOP_TARGET_ARCH: target.arch }) resolveDesktopAppId(environment) resolveNpmRegistry(environment) if (target.platform !== 'darwin' || !options.unsigned) resolveDesktopPolicyEnvironment(environment) diff --git a/apps/desktop/scripts/prepare-dsh.ts b/apps/desktop/scripts/prepare-dsh.ts index 365c5a45f2..3262fcd62a 100644 --- a/apps/desktop/scripts/prepare-dsh.ts +++ b/apps/desktop/scripts/prepare-dsh.ts @@ -39,7 +39,7 @@ const APP_ROOT = resolve(import.meta.dirname, '..') const BUILD_PATHS = resolveDesktopTargetBuildPaths() const DSH_OUTPUT_ROOT = BUILD_PATHS.dsh const BUILD_ROOT = mkdtempSync(join(tmpdir(), 'dsh-desktop-runtime-')) -const STORE_ROOT = join(BUILD_ROOT, 'store') +const STORE_ROOT = BUILD_PATHS.pnpmStore const RUNTIME_ROOT = BUILD_PATHS.runtime const PNPM_BUILD_STATE = BUILD_PATHS.dshPnpm const PACKAGE_SET_ROOT = BUILD_PATHS.packageSet @@ -83,6 +83,8 @@ function runPnpm(args: readonly string[]): Promise { `--config.registry=${registry}`, `--config.store-dir=${STORE_ROOT}`, '--config.enable-global-virtual-store=false', + '--config.verify-store-integrity=true', + '--config.package-import-method=copy', `--config.userconfig=${userConfig}`, command, ...commandArgs, diff --git a/apps/desktop/tests/desktop-build-paths.spec.ts b/apps/desktop/tests/desktop-build-paths.spec.ts index ede5c5e3d3..2fc0d09ed9 100644 --- a/apps/desktop/tests/desktop-build-paths.spec.ts +++ b/apps/desktop/tests/desktop-build-paths.spec.ts @@ -35,9 +35,10 @@ describe('desktop build paths', () => { expect(windows.runtime).toContain(join('targets', 'win-x64', 'runtime')) }) - it('shares only the immutable upstream download cache', () => { + it('shares upstream downloads and the integrity-checked package store', () => { const arm64 = desktopTargetBuildPaths('mac-arm64') const x64 = desktopTargetBuildPaths('mac-x64') + expect(arm64.pnpmStore).toBe(x64.pnpmStore) expect(arm64.downloads).toBe(x64.downloads) expect(arm64.downloads).not.toContain(`${sep}targets${sep}`) }) @@ -68,3 +69,17 @@ describe('desktop build paths', () => { expect(() => desktopTargetBuildPaths('linux-x64' as 'mac-x64')).toThrow(/unsupported target/u) }) }) + +it('keeps caches outside the disposable target directories when explicitly configured', () => { + const cache = join(process.cwd(), 'external-build-cache') + const environment = { DSH_DESKTOP_PACKAGE_CACHE_DIR: cache } + for (const target of ['mac-arm64', 'mac-x64', 'win-x64'] as const) { + const paths = desktopTargetBuildPaths(target, environment) + expect(paths.downloads).toBe(join(cache, 'downloads')) + expect(paths.pnpmStore).toBe(join(cache, 'pnpm-store')) + expect(paths.root).not.toContain(cache) + } + for (const value of ['', 'relative-cache']) { + expect(() => desktopTargetBuildPaths('mac-arm64', { DSH_DESKTOP_PACKAGE_CACHE_DIR: value })).toThrow('must be absolute') + } +}) diff --git a/apps/desktop/tests/desktop-package-environment.spec.ts b/apps/desktop/tests/desktop-package-environment.spec.ts index 6116cb718a..fdd2b00fa9 100644 --- a/apps/desktop/tests/desktop-package-environment.spec.ts +++ b/apps/desktop/tests/desktop-package-environment.spec.ts @@ -272,3 +272,8 @@ it('skips Apple credentials but still requires signing credentials for unnotariz }).toThrow(/CSC_KEY_PASSWORD/u) }) }) + +it.each(['', 'relative-cache'])('rejects invalid build cache paths during preflight (%s)', (cache) => { + expect(() => { validateDesktopPackageEnvironment({ ...RELEASE, DSH_DESKTOP_PACKAGE_CACHE_DIR: cache }, MACOS, { unsigned: true }) }) + .toThrow('DSH_DESKTOP_PACKAGE_CACHE_DIR must be absolute') +}) diff --git a/scripts/macos-package-workflow.spec.ts b/scripts/macos-package-workflow.spec.ts index fb85352d0e..8d8e4df3f7 100644 --- a/scripts/macos-package-workflow.spec.ts +++ b/scripts/macos-package-workflow.spec.ts @@ -9,6 +9,7 @@ const workflow = yaml.load(readFileSync(resolve(import.meta.dirname, '../.github concurrency: { group: string; 'cancel-in-progress': boolean } jobs: Record steps: Array<{ id?: string; name?: string; if?: string; uses?: string; run?: string; with?: Record }> }> } @@ -40,3 +41,9 @@ it('defaults to notarization and labels the explicit debugging option', () => { expect(steps.filter(step => step.run?.includes('args+=(--skip-notarization)'))).toHaveLength(2) expect(steps.find(step => step.name === 'Retain installers for the selected mode')?.with?.name).toContain('${{ steps.settings.outputs.mode }}') }) + +it('retains build inputs outside checkout and run-specific pnpm installations', () => { + const env = workflow.jobs.package!.env + expect(env.DSH_DESKTOP_PACKAGE_CACHE_DIR).toBe('${{ runner.tool_cache }}/dsh-desktop-package-cache') + expect(env.PNPM_CONFIG_STORE_DIR).toBe('${{ runner.tool_cache }}/dsh-desktop-package-cache/workspace-pnpm-store') +}) diff --git a/scripts/windows-package-workflow.spec.ts b/scripts/windows-package-workflow.spec.ts index 9be7cd8f6c..56d4e58370 100644 --- a/scripts/windows-package-workflow.spec.ts +++ b/scripts/windows-package-workflow.spec.ts @@ -61,6 +61,7 @@ it('routes manual packaging to the dedicated runner with explicit preset and sig expect(Object.keys(workflow.on)).toEqual(['workflow_dispatch']) expect(workflow.jobs.package).not.toHaveProperty('name') expect(job['runs-on']).toBe('dsh-win-package-trial') + expect(job.env.DSH_DESKTOP_PACKAGE_CACHE_DIR).toBe('${{ runner.tool_cache }}/dsh-desktop-package-cache') expect(workflow.concurrency).toEqual({ group: 'windows-desktop-package', 'cancel-in-progress': false }) expect(workflow.permissions).toEqual({ contents: 'read' }) expect(Object.keys(workflow.on.workflow_dispatch!.inputs).sort()).toEqual([ @@ -107,3 +108,25 @@ it('retains failure diagnostics and uploads only the selected successful install expect(upload.with).toMatchObject({ path: '${{ steps.retained.outputs.directory }}/*', 'if-no-files-found': 'error' }) } }) + +it.skipIf(process.platform !== 'win32')('keeps the dependency store stable across run-specific pnpm homes', () => { + const directory = mkdtempSync(resolve(tmpdir(), 'windows-package-store-')) + try { + const step = job.steps.find(entry => entry.name === 'Configure persistent pnpm store')! + expect(job.steps.indexOf(step)).toBeLessThan(job.steps.findIndex(entry => entry.name === 'Install locked dependencies')) + const script = resolve(directory, 'store.ps1') + writeFileSync(script, step.run!) + const outputs = ['first', 'second'].map((run) => { + const environmentFile = resolve(directory, run + '.env') + execFileSync('pwsh', ['-NoProfile', '-File', script], { + env: { ...process.env, GITHUB_WORKSPACE: 'D:\\runner\\_work\\repo\\repo', + PNPM_HOME: resolve(directory, run, 'pnpm'), GITHUB_ENV: environmentFile }, + stdio: 'pipe', + }) + return readFileSync(environmentFile, 'utf8').trim() + }) + expect(outputs).toEqual(['PNPM_CONFIG_STORE_DIR=D:\\.pnpm-store', 'PNPM_CONFIG_STORE_DIR=D:\\.pnpm-store']) + } finally { + rmSync(directory, { recursive: true, force: true, maxRetries: 3 }) + } +}) From df12de44bb2ecaa369d43eb905284a9ce5c22a99 Mon Sep 17 00:00:00 2001 From: winewill Date: Tue, 20 Oct 2026 13:47:34 +0800 Subject: [PATCH 02/46] perf(desktop): default final macOS ZIP compression to level 3 --- apps/desktop/.env.macos.example | 1 + .../macos-packaging/production.env.example | 1 + .../config/macos-packaging/test.env.example | 1 + apps/desktop/packaging/README.i18n.yaml | 4 ++-- apps/desktop/packaging/README.md | 1 + apps/desktop/packaging/README.zh.md | 1 + .../scripts/desktop-package-environment.mjs | 4 ++-- .../desktop/scripts/macos-package-settings.d.mts | 1 + apps/desktop/scripts/macos-package-settings.mjs | 7 ++++++- apps/desktop/scripts/package-target.ts | 6 ++++-- .../tests/desktop-package-environment.spec.ts | 11 +++++++++++ .../desktop/tests/macos-package-settings.spec.ts | 14 +++++++++++--- apps/desktop/tests/package-target-stages.spec.ts | 16 ++++++++++++++++ 13 files changed, 58 insertions(+), 10 deletions(-) diff --git a/apps/desktop/.env.macos.example b/apps/desktop/.env.macos.example index da5502ce5d..1411cf52df 100644 --- a/apps/desktop/.env.macos.example +++ b/apps/desktop/.env.macos.example @@ -14,6 +14,7 @@ DOWNLOAD_TEST_RELEASE_ID= # Workspace tarball workers; omit to use 4. Must be a positive integer. DSH_DESKTOP_MACOS_PACK_CONCURRENCY=4 +DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL=3 # 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= diff --git a/apps/desktop/config/macos-packaging/production.env.example b/apps/desktop/config/macos-packaging/production.env.example index 18360eadef..483c38543f 100644 --- a/apps/desktop/config/macos-packaging/production.env.example +++ b/apps/desktop/config/macos-packaging/production.env.example @@ -3,6 +3,7 @@ DSH_DESKTOP_APP_ID=com.deepseek.harness DSH_DESKTOP_AUTO_UPDATE_ENV=production DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN= DSH_DESKTOP_MACOS_PACK_CONCURRENCY=4 +DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL=3 DSH_DESKTOP_MACOS_DOWNLOAD_PROXY= DSH_DESKTOP_MACOS_NOTARIZATION_PROXY= # Optional absolute private directory; setup fills this in for machine presets. Omit to use checkout-local storage. diff --git a/apps/desktop/config/macos-packaging/test.env.example b/apps/desktop/config/macos-packaging/test.env.example index aa40a119ce..4926c427db 100644 --- a/apps/desktop/config/macos-packaging/test.env.example +++ b/apps/desktop/config/macos-packaging/test.env.example @@ -7,6 +7,7 @@ DOWNLOAD_TEST_ORIGIN= # Generate a 32-character lowercase hexadecimal release ID; retain it for upgrade testing. DOWNLOAD_TEST_RELEASE_ID= DSH_DESKTOP_MACOS_PACK_CONCURRENCY=4 +DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL=3 DSH_DESKTOP_MACOS_DOWNLOAD_PROXY= # Temporarily changes the active network service's system HTTP/HTTPS proxies. DSH_DESKTOP_MACOS_NOTARIZATION_PROXY= diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index aac571f1f2..7df08d754b 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -36,8 +36,8 @@ en: 2feaa0412247d81f zh: 461dde8e13fd4734 /desktop-packaging/macos-release-signing-and-diagnostics: - en: f4a2b14da34ba883 - zh: 6292f3a7b4038213 + en: be48e5f7205fc058 + zh: 3be2e0bf2180d413 /desktop-packaging/directory-builds-and-preparation: en: 5860c306375ddd63 zh: a76e3cdbbe25c79e diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index 0255960a2c..d90547b1cf 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -186,6 +186,7 @@ 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_ZIP_COMPRESSION_LEVEL` | `3` | Final update ZIP compression, integer `0`–`9`; lower levels trade larger downloads for shorter builds. App notarization archives and DMG compression are unchanged. | | `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. | diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index de9d86eb20..fa9dab7afc 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -186,6 +186,7 @@ Mac 打包从 `.env.macos` 读取三个调优字段: | 设置 | 默认值 | 作用范围 | |---|---|---| | `DSH_DESKTOP_MACOS_PACK_CONCURRENCY` | `4` | 第一方与 vendor workspace tarball 的打包 worker 数;必须是正整数。 | +| `DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL` | `3` | 最终更新 ZIP 的压缩等级,整数 `0`–`9`;较低等级以更大的下载体积换取更短的构建时间。App 公证归档和 DMG 压缩不变。 | | `DSH_DESKTOP_MACOS_DOWNLOAD_PROXY` | 空 | Electron、运行时资源、pnpm 安装及 builder 下载使用的 HTTP/HTTPS 代理 origin。 | | `DSH_DESKTOP_MACOS_NOTARIZATION_PROXY` | 空 | 通过临时系统代理设置供 Apple 工具使用的 HTTP 代理 origin。 | diff --git a/apps/desktop/scripts/desktop-package-environment.mjs b/apps/desktop/scripts/desktop-package-environment.mjs index 81e1bf5274..03beda5efb 100644 --- a/apps/desktop/scripts/desktop-package-environment.mjs +++ b/apps/desktop/scripts/desktop-package-environment.mjs @@ -17,7 +17,7 @@ import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' const APP_ROOT = fileURLToPath(new URL('..', import.meta.url)) const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_RELEASE_ID|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|SIGNATURE_CACHE_CONCURRENCY|PACK_CONCURRENCY)$/u -const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|SIGNATURE_CACHE_DIR|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 MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|SIGNATURE_CACHE_DIR|PACK_CONCURRENCY|ZIP_COMPRESSION_LEVEL|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'] @@ -55,7 +55,7 @@ export function loadDesktopPackageEnvironment(platform, environment = process.en const platformSetting = platform === 'win32' ? WINDOWS_SETTING : MACOS_SETTING const publicPlatformSetting = platform === 'win32' ? /^DSH_DESKTOP_WINDOWS_(?:SIGNATURE_CACHE_(?:DIR|CONCURRENCY)|PACK_CONCURRENCY)$/u - : /^DSH_DESKTOP_MACOS_(?:SIGNATURE_CACHE_DIR|PACK_CONCURRENCY|DOWNLOAD_PROXY|NOTARIZATION_PROXY)$/u + : /^DSH_DESKTOP_MACOS_(?:SIGNATURE_CACHE_DIR|PACK_CONCURRENCY|ZIP_COMPRESSION_LEVEL|DOWNLOAD_PROXY|NOTARIZATION_PROXY)$/u const settings = readSettings(path, name => preset === undefined ? SHARED_SETTING.test(name) || platformSetting.test(name) : /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_(?:ORIGIN|RELEASE_ID))$/u.test(name) || publicPlatformSetting.test(name)) diff --git a/apps/desktop/scripts/macos-package-settings.d.mts b/apps/desktop/scripts/macos-package-settings.d.mts index 09b991752e..842b01804e 100644 --- a/apps/desktop/scripts/macos-package-settings.d.mts +++ b/apps/desktop/scripts/macos-package-settings.d.mts @@ -3,6 +3,7 @@ /** Validated settings shared by preflight and stage execution. */ export interface MacOSPackageSettings { readonly packConcurrency: number + readonly zipCompressionLevel: number readonly downloadProxy?: string readonly notarizationProxy?: string } diff --git a/apps/desktop/scripts/macos-package-settings.mjs b/apps/desktop/scripts/macos-package-settings.mjs index 1ed93c3377..4d1f28e257 100644 --- a/apps/desktop/scripts/macos-package-settings.mjs +++ b/apps/desktop/scripts/macos-package-settings.mjs @@ -15,14 +15,19 @@ function proxy(environment, name, protocols) { /** * 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. + * @returns {{packConcurrency: number, zipCompressionLevel: 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') } + const compression = environment.DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL ?? '3' + if (!/^[0-9]$/u.test(compression)) { + throw new Error('desktop package: DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL must be an integer from 0 to 9') + } return { + zipCompressionLevel: Number(compression), packConcurrency: Number(concurrency), downloadProxy: proxy(environment, 'DSH_DESKTOP_MACOS_DOWNLOAD_PROXY', ['http:', 'https:']), notarizationProxy: proxy(environment, 'DSH_DESKTOP_MACOS_NOTARIZATION_PROXY', ['http:']), diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index c3dd201989..ee04cfb901 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -380,7 +380,7 @@ async function main(): Promise { await packagingStep(run.directory, 'toolchain', () => requireDesktopToolchain(target.platform, environment), secrets) if (target.platform === 'darwin') { const settings = resolveMacOSPackageSettings(environment) - recordPackagingEvent(run.directory, { type: 'macos-settings', packConcurrency: settings.packConcurrency, + recordPackagingEvent(run.directory, { type: 'macos-settings', packConcurrency: settings.packConcurrency, zipCompressionLevel: settings.zipCompressionLevel, downloadProxyConfigured: settings.downloadProxy !== undefined, notarizationProxyConfigured: settings.notarizationProxy !== undefined }) await packagingStep(run.directory, 'macos-package', () => invocation.unsigned @@ -518,7 +518,9 @@ export async function packageTarget( version: resolveDesktopBuildVersion(environment, packageVersion(join(APP_ROOT, 'package.json'), 'desktop package')), artifactsRoot: buildPaths.artifacts, environment: electronBuilderEnv, - }, artifact => execute(desktopElectronBuilderArguments(target, false, artifact), electronBuilderEnv)), undefined, undefined, proxyEvent) + }, artifact => execute(desktopElectronBuilderArguments(target, false, artifact), artifact.format === 'zip' + ? { ...electronBuilderEnv, ELECTRON_BUILDER_COMPRESSION_LEVEL: String(mac?.zipCompressionLevel) } + : electronBuilderEnv)), undefined, undefined, proxyEvent) } else if (target.platform === 'darwin' && !invocation.unsigned && !invocation.skipNotarization) { await execute([...desktopElectronBuilderArguments(target, true), '--config.mac.notarize=false'], electronBuilderEnv) await execute(['exec', 'tsx', 'scripts/smoke-packaged-runtime.ts'], targetEnv) diff --git a/apps/desktop/tests/desktop-package-environment.spec.ts b/apps/desktop/tests/desktop-package-environment.spec.ts index fdd2b00fa9..9ac37c7505 100644 --- a/apps/desktop/tests/desktop-package-environment.spec.ts +++ b/apps/desktop/tests/desktop-package-environment.spec.ts @@ -273,6 +273,17 @@ it('skips Apple credentials but still requires signing credentials for unnotariz }) }) +it('takes ZIP compression from the Mac file instead of ambient release settings', async () => { + await withDirectory(async (directory) => { + await writeFile(join(directory, '.env.macos'), 'DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL=5\n') + const loaded = loadDesktopPackageEnvironment('darwin', { DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL: '9' }, directory) + expect(loaded.DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL).toBe('5') + await writeFile(join(directory, '.env.macos'), '') + expect(loadDesktopPackageEnvironment('darwin', { DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL: '9' }, directory)) + .not.toHaveProperty('DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL') + }) +}) + it.each(['', 'relative-cache'])('rejects invalid build cache paths during preflight (%s)', (cache) => { expect(() => { validateDesktopPackageEnvironment({ ...RELEASE, DSH_DESKTOP_PACKAGE_CACHE_DIR: cache }, MACOS, { unsigned: true }) }) .toThrow('DSH_DESKTOP_PACKAGE_CACHE_DIR must be absolute') diff --git a/apps/desktop/tests/macos-package-settings.spec.ts b/apps/desktop/tests/macos-package-settings.spec.ts index 1daa31c70a..449a453fda 100644 --- a/apps/desktop/tests/macos-package-settings.spec.ts +++ b/apps/desktop/tests/macos-package-settings.spec.ts @@ -2,12 +2,12 @@ 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({})).toEqual({ packConcurrency: 4, zipCompressionLevel: 3 }) 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 }) + })).toEqual({ packConcurrency: 2, zipCompressionLevel: 3, 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, zipCompressionLevel: 3 }) }) it.each(['', '0', '-1', '1.5', '1e2', 'Infinity', '9007199254740992'])('rejects invalid concurrency %j', (value) => { @@ -32,3 +32,11 @@ it('overrides conflicting child proxy variables while preserving the parent and expect(parent.HTTPS_PROXY).toBe('http://old') expect(parent.NO_PROXY).toBe('*') }) + +it.each(['', '-1', '10', '3.5', '03', 'NaN'])('rejects invalid ZIP compression %j', (value) => { + expect(() => resolveMacOSPackageSettings({ DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL: value })).toThrow('ZIP_COMPRESSION_LEVEL') +}) + +it.each(['0', '3', '7', '9'])('accepts ZIP compression %s', (value) => { + expect(resolveMacOSPackageSettings({ DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL: value }).zipCompressionLevel).toBe(Number(value)) +}) diff --git a/apps/desktop/tests/package-target-stages.spec.ts b/apps/desktop/tests/package-target-stages.spec.ts index 9ca7e94e05..e10e1d08f7 100644 --- a/apps/desktop/tests/package-target-stages.spec.ts +++ b/apps/desktop/tests/package-target-stages.spec.ts @@ -271,3 +271,19 @@ it('fails debugging packages when runtime verification fails', async () => { expect(stages.at(-1)).toBe(failure) expect(writeFileSync).not.toHaveBeenCalled() }) + +it.each([undefined, '7'])('applies file-owned compression only to the final notarized ZIP (%s)', async (level) => { + const { run } = supervisor() + vi.mocked(packageMacOSArtifacts).mockImplementationOnce(async (_request, build) => { + await build({ format: 'dmg', appPath: '/fixture/app', output: '/fixture/dmg' }) + await build({ format: 'zip', appPath: '/fixture/app', output: '/fixture/zip' }) + }) + const settings = { ...environment, ELECTRON_BUILDER_COMPRESSION_LEVEL: '9', + ...(level === undefined ? {} : { DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL: level }) } + await packageTarget(parseDesktopPackageInvocation(['mac-arm64'], 'darwin', 'arm64'), settings, run) + const zip = run.run.mock.calls.find(call => call[0].includes('--mac zip')) + const dmg = run.run.mock.calls.find(call => call[0].includes('--mac dmg')) + expect(zip?.[3].env.ELECTRON_BUILDER_COMPRESSION_LEVEL).toBe(level ?? '3') + expect(dmg?.[3].env.ELECTRON_BUILDER_COMPRESSION_LEVEL).toBe('9') + expect(writeFileSync).toHaveBeenCalledOnce() +}) From 8b70fa9af552a036f3abb589b5e37f400fcd01d6 Mon Sep 17 00:00:00 2001 From: winewill Date: Tue, 20 Oct 2026 13:47:35 +0800 Subject: [PATCH 03/46] feat(ci): offer macOS ZIP compression choices in packaging workflow --- .github/workflows/macos-package.yml | 7 +++++++ apps/desktop/packaging/README.i18n.yaml | 4 ++-- apps/desktop/packaging/README.md | 2 +- apps/desktop/packaging/README.zh.md | 2 +- .../scripts/desktop-package-environment.mjs | 6 ++++++ .../tests/desktop-package-environment.spec.ts | 16 ++++++++++++++++ scripts/macos-package-workflow.spec.ts | 7 +++++++ 7 files changed, 40 insertions(+), 4 deletions(-) diff --git a/.github/workflows/macos-package.yml b/.github/workflows/macos-package.yml index 7e533fa323..01fa722c0f 100644 --- a/.github/workflows/macos-package.yml +++ b/.github/workflows/macos-package.yml @@ -9,6 +9,12 @@ on: options: [test, production] default: test required: true + zip_compression_level: + description: 'ZIP 压缩等级:只影响 ZIP 和自动升级下载,不影响 DMG;3 打包较快,7 压缩更强' + type: choice + options: ['3', '7'] + default: '3' + required: true skip_notarization: description: '跳过公证和 stapling:仅限内部调试,禁止发布到 test / production 或用于自动更新;需要发布请勿勾选' type: boolean @@ -57,6 +63,7 @@ jobs: run: shell: bash env: + PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL: ${{ inputs.zip_compression_level }} PACKAGE_SKIP_NOTARIZATION: ${{ inputs.skip_notarization }} PACKAGE_SOURCE_REF: ${{ inputs.source_ref }} PACKAGE_VERSION_MODE: ${{ inputs.version_mode }} diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index 7df08d754b..88f9613a0a 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -27,8 +27,8 @@ en: e1b101593f6f755e zh: 121f225de399de1e /desktop-packaging/macos-self-hosted-packaging: - en: a3af9523fdc60036 - zh: 5e18d4740e9b7f57 + en: 734d1df945da8f45 + zh: 2b17a3039efdde78 /desktop-packaging/unsigned-windows-test-installer: en: 6843a0fdc03ead58 zh: 957f92fc8126e23e diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index d90547b1cf..73682248b0 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -119,7 +119,7 @@ Open the completed run in Actions and download its Artifacts. `windows-x64-signe ## macOS self-hosted packaging -The `Package Desktop · macOS` [manual workflow](../../../.github/workflows/macos-package.yml) targets an Apple Silicon runner labeled `dsh-mac-package-trial`. It selects `test` or `production`, resolves `build_version=auto` from the UTC date, run number and attempt, and uses the existing signing, notarization and packaged-runtime checks. Successful DMG/ZIP files and diagnostic logs are retained for 14 days; this workflow does not publish a release. The operator must register the runner and verify a complete Actions run before relying on it. +The `Package Desktop · macOS` [manual workflow](../../../.github/workflows/macos-package.yml) targets an Apple Silicon runner labeled `dsh-mac-package-trial`. It selects `test` or `production`, resolves `build_version=auto` from the UTC date, run number and attempt, and uses the existing signing, notarization and packaged-runtime checks. Successful DMG/ZIP files and diagnostic logs are retained for 14 days; this workflow does not publish a release. The operator must register the runner and verify a complete Actions run before relying on it. The `zip_compression_level` dropdown defaults to `3` and also offers `7`; it overrides the machine preset for this run and affects only the ZIP and automatic-update downloads, not the DMG. Run `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/dsh-ci"` to prepare private `runner/`, `packaging/{presets,secrets,certificates}` and `cache/macos-signatures` directories without overwriting existing files; append `--check` to inspect them. Fill the [preset](../config/macos-packaging/test.env.example) and [signing](../config/macos-packaging/macos-signing.env.example) templates outside the checkout. Set secret and certificate files to mode `0600`. `DSH_PACKAGING_CONFIG_DIR` selects the absolute `packaging` directory and `DSH_PACKAGING_PRESET` selects its preset; missing files fail without falling back to `.env.macos`. Signed and preparation-only Mac builds read `secrets/macos-signing.env`; relative credential paths resolve beside that file. Public presets reject signing credentials, and signing files cannot override public settings. Local unsigned Mac builds retain their configuration-free behavior. diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index fa9dab7afc..b96d90133e 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -119,7 +119,7 @@ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-s ## macOS 自托管打包 -`Package Desktop · macOS` [手动工作流](../../../.github/workflows/macos-package.yml) 使用带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner。它选择 `test` 或 `production`,以 UTC 日期、运行编号和重试次数解析 `build_version=auto`,并复用现有签名、公证和打包运行时检查。成功的 DMG/ZIP 与诊断日志保留 14 天;工作流不发布 release。操作人员须注册 runner,并验收完整 Actions 运行后再依赖该流程。 +`Package Desktop · macOS` [手动工作流](../../../.github/workflows/macos-package.yml) 使用带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner。它选择 `test` 或 `production`,以 UTC 日期、运行编号和重试次数解析 `build_version=auto`,并复用现有签名、公证和打包运行时检查。成功的 DMG/ZIP 与诊断日志保留 14 天;工作流不发布 release。操作人员须注册 runner,并验收完整 Actions 运行后再依赖该流程。 `zip_compression_level` 下拉选项默认为 `3`,也可选择 `7`;本次运行以该选项覆盖机器预设,只影响 ZIP 和自动升级下载,不影响 DMG。 运行 `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/dsh-ci"` 创建私有 `runner/`、`packaging/{presets,secrets,certificates}` 和 `cache/macos-signatures` 目录,不覆盖已有文件;追加 `--check` 仅检查。在 checkout 外填写 [preset](../config/macos-packaging/test.env.example) 和[签名](../config/macos-packaging/macos-signing.env.example)模板,将秘密和证书文件权限设为 `0600`。`DSH_PACKAGING_CONFIG_DIR` 指定 `packaging` 的绝对目录,`DSH_PACKAGING_PRESET` 选择其中的 preset;缺少文件立即失败,不回退到 `.env.macos`。Mac 签名和仅准备构建读取 `secrets/macos-signing.env`,相对凭据路径以该文件所在目录为基准。公开 preset 拒绝签名凭据,签名文件不能覆盖公开配置。本地未签名 Mac 构建保留无需配置的行为。 diff --git a/apps/desktop/scripts/desktop-package-environment.mjs b/apps/desktop/scripts/desktop-package-environment.mjs index 03beda5efb..11cef3ad86 100644 --- a/apps/desktop/scripts/desktop-package-environment.mjs +++ b/apps/desktop/scripts/desktop-package-environment.mjs @@ -36,6 +36,7 @@ export function localMacOSPackageEnvironment(environment = process.env) { /** * Read a machine preset when selected, otherwise the target's required dotenv; no ambient release fallback. + * PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL selects 3 or 7 for Mac workflow builds, overriding the file setting. * PACKAGE_TEST_RELEASE_ID overrides the test preset only; blank retains its configured ID. * @param {'win32' | 'darwin'} platform Target platform. * @param {NodeJS.ProcessEnv} environment Parent environment, retained only for unrelated build tools. @@ -69,6 +70,11 @@ export function loadDesktopPackageEnvironment(platform, environment = process.en : MACOS_SETTING.test(name) && !publicPlatformSetting.test(name))) } } + const zipCompression = environment.PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL + if (platform === 'darwin' && zipCompression !== undefined) { + if (!['3', '7'].includes(zipCompression)) throw new Error('desktop package: workflow ZIP compression must be 3 or 7') + settings.DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL = zipCompression + } const releaseId = environment.PACKAGE_TEST_RELEASE_ID?.trim() if (releaseId) { if (preset !== 'test') throw new Error('desktop package: test release ID override requires the test preset') diff --git a/apps/desktop/tests/desktop-package-environment.spec.ts b/apps/desktop/tests/desktop-package-environment.spec.ts index 9ac37c7505..42d04a8e44 100644 --- a/apps/desktop/tests/desktop-package-environment.spec.ts +++ b/apps/desktop/tests/desktop-package-environment.spec.ts @@ -288,3 +288,19 @@ it.each(['', 'relative-cache'])('rejects invalid build cache paths during prefli expect(() => { validateDesktopPackageEnvironment({ ...RELEASE, DSH_DESKTOP_PACKAGE_CACHE_DIR: cache }, MACOS, { unsigned: true }) }) .toThrow('DSH_DESKTOP_PACKAGE_CACHE_DIR must be absolute') }) + +it.each(['3', '7'])('applies the workflow ZIP choice %s over the Mac file setting', async (level) => { + await withDirectory(async (directory) => { + await writeFile(join(directory, '.env.macos'), 'DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL=5\n') + const loaded = loadDesktopPackageEnvironment('darwin', { PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL: level }, directory) + expect(loaded.DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL).toBe(level) + }) +}) + +it.each(['', '0', '9', 'invalid'])('rejects unsupported workflow ZIP choice %j', async (level) => { + await withDirectory(async (directory) => { + await writeFile(join(directory, '.env.macos'), '') + expect(() => loadDesktopPackageEnvironment('darwin', { PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL: level }, directory)) + .toThrow('workflow ZIP compression must be 3 or 7') + }) +}) diff --git a/scripts/macos-package-workflow.spec.ts b/scripts/macos-package-workflow.spec.ts index 8d8e4df3f7..92ae1911a6 100644 --- a/scripts/macos-package-workflow.spec.ts +++ b/scripts/macos-package-workflow.spec.ts @@ -47,3 +47,10 @@ it('retains build inputs outside checkout and run-specific pnpm installations', expect(env.DSH_DESKTOP_PACKAGE_CACHE_DIR).toBe('${{ runner.tool_cache }}/dsh-desktop-package-cache') expect(env.PNPM_CONFIG_STORE_DIR).toBe('${{ runner.tool_cache }}/dsh-desktop-package-cache/workspace-pnpm-store') }) + +it('offers ZIP compression choices with a fast default', () => { + expect(workflow.on.workflow_dispatch).toMatchObject({ inputs: { zip_compression_level: { + type: 'choice', options: ['3', '7'], default: '3', required: true, + } } }) + expect(workflow.jobs.package!.env.PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL).toBe('${{ inputs.zip_compression_level }}') +}) From 03152d6ca40bf16f789c9ddf6fadf1e8402cdf9f Mon Sep 17 00:00:00 2001 From: winewill Date: Tue, 20 Oct 2026 13:47:18 +0800 Subject: [PATCH 04/46] perf(desktop): persist download and pnpm packaging caches --- .github/workflows/macos-package.yml | 2 ++ .github/workflows/windows-package.yml | 6 +++++ apps/desktop/packaging/README.i18n.yaml | 4 ++-- apps/desktop/packaging/README.md | 2 ++ apps/desktop/packaging/README.zh.md | 2 ++ .../desktop/scripts/desktop-build-paths.d.mts | 8 ++++--- apps/desktop/scripts/desktop-build-paths.mjs | 14 +++++++---- .../scripts/desktop-package-environment.mjs | 2 ++ apps/desktop/scripts/prepare-dsh.ts | 4 +++- .../desktop/tests/desktop-build-paths.spec.ts | 17 +++++++++++++- .../tests/desktop-package-environment.spec.ts | 5 ++++ scripts/macos-package-workflow.spec.ts | 7 ++++++ scripts/windows-package-workflow.spec.ts | 23 +++++++++++++++++++ 13 files changed, 84 insertions(+), 12 deletions(-) diff --git a/.github/workflows/macos-package.yml b/.github/workflows/macos-package.yml index b0c3164729..7e533fa323 100644 --- a/.github/workflows/macos-package.yml +++ b/.github/workflows/macos-package.yml @@ -62,6 +62,8 @@ jobs: PACKAGE_VERSION_MODE: ${{ inputs.version_mode }} DSH_DESKTOP_UPDATE_CHANNEL: ${{ inputs.update_channel }} DSH_TELEMETRY_DISABLED: '1' + DSH_DESKTOP_PACKAGE_CACHE_DIR: ${{ runner.tool_cache }}/dsh-desktop-package-cache + PNPM_CONFIG_STORE_DIR: ${{ runner.tool_cache }}/dsh-desktop-package-cache/workspace-pnpm-store DSH_PACKAGING_PRESET: ${{ inputs.preset }} PACKAGE_TEST_RELEASE_ID: ${{ inputs.test_release_id }} steps: diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index bc76eca87b..d2a00a7f3f 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -62,6 +62,7 @@ jobs: PACKAGE_VERSION_MODE: ${{ inputs.version_mode }} DSH_DESKTOP_UPDATE_CHANNEL: ${{ inputs.update_channel }} DSH_TELEMETRY_DISABLED: '1' + DSH_DESKTOP_PACKAGE_CACHE_DIR: ${{ runner.tool_cache }}/dsh-desktop-package-cache DSH_PACKAGING_PRESET: ${{ inputs.preset }} PACKAGE_TEST_RELEASE_ID: ${{ inputs.test_release_id }} PACKAGE_SIGNING: ${{ inputs.signing }} @@ -107,6 +108,11 @@ jobs: - uses: actions/setup-node@v7.0.0 with: node-version: '24' + - name: Configure persistent pnpm store + # PNPM_HOME belongs to this run; keep downloaded packages outside its temporary directory. + run: | + $storeRoot = Join-Path ([IO.Path]::GetPathRoot($env:GITHUB_WORKSPACE)) '.pnpm-store' + "PNPM_CONFIG_STORE_DIR=$storeRoot" >> $env:GITHUB_ENV - name: Install locked dependencies run: | pnpm install --frozen-lockfile diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index 96c2ee0cf0..aac571f1f2 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -15,8 +15,8 @@ en: a6203dc2e690c7e1 zh: 07b516549b8e3328 /desktop-packaging/choose-a-build-path: - en: 17c6bcdefa05a8f8 - zh: 4ee18f832276144d + en: 447e66fb59b03fb1 + zh: a87d440407b4bde3 /desktop-packaging/release-versions: en: 3f38ab26ee195b39 zh: f00eddafa8f3b9ca diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index a77d260591..0255960a2c 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -32,6 +32,8 @@ Build Desktop installers locally or through Windows or macOS Actions, choose dep Run local commands from the repository root after installing locked dependencies. Use platform-specific commands for a local build, or platform Actions for the registered self-hosted runner. Choose test or production deployment independently from signed or unsigned mode. Read [Windows signing and runner operations](../windows-signing/README.md#trust-and-isolation) before any Windows signed build or shared-runner use. Installation is separate from packaging; [publication](../publishing/README.md) is a further explicit operation. +Set `DSH_DESKTOP_PACKAGE_CACHE_DIR` to an absolute build-cache directory to retain verified downloads and the pnpm content store across checkouts. Both packaging workflows use `runner.tool_cache/dsh-desktop-package-cache`; local commands default to `.desktop-build`. Runtime preparation still creates a fresh installation and lockfile, verifies stored package integrity, and copies files before signing. Cleanup removes temporary installations, not these caches. Operators manage cache disk usage; no automatic eviction runs. + ## Release versions diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index 54e9d740f8..de9d86eb20 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -32,6 +32,8 @@ description: "在本地或 Windows/macOS Actions 中生成 Desktop 安装包 安装锁定依赖后,从仓库根目录运行本地命令。本地构建使用平台命令,已注册的自托管 runner 使用对应平台的 Actions。test/production 部署与 signed/unsigned 模式分别选择。任何 Windows 签名构建或共享 runner 使用前,先阅读 [Windows 签名与 runner 运维](../windows-signing/README.zh.md#trust-and-isolation)。安装与打包分开进行;[发布](../publishing/README.zh.md)还需要单独的显式操作。 +将 `DSH_DESKTOP_PACKAGE_CACHE_DIR` 设为绝对构建缓存目录,即可跨 checkout 保留经过校验的下载文件和 pnpm 内容存储。两个打包工作流使用 `runner.tool_cache/dsh-desktop-package-cache`;本地命令默认使用 `.desktop-build`。runtime 准备仍创建全新安装目录和锁文件、校验缓存包的完整性,并在签名前复制文件。清理只删除临时安装,不删除这些缓存。缓存磁盘用量由操作者管理,不自动淘汰。 + ## 发布版本 diff --git a/apps/desktop/scripts/desktop-build-paths.d.mts b/apps/desktop/scripts/desktop-build-paths.d.mts index c7b8173ca1..c069dda3f7 100644 --- a/apps/desktop/scripts/desktop-build-paths.d.mts +++ b/apps/desktop/scripts/desktop-build-paths.d.mts @@ -1,6 +1,6 @@ import type { DesktopAutoUpdateTarget } from './desktop-auto-update-environment.mjs' -/** Mutable target directories plus the shared immutable download cache. */ +/** Mutable target directories plus shared download and package stores. */ export interface DesktopTargetBuildPaths { readonly root: string readonly artifacts: string @@ -15,6 +15,7 @@ export interface DesktopTargetBuildPaths { readonly packedVendor: string readonly packedLandlock: string readonly downloads: string + readonly pnpmStore: string } /** @@ -33,9 +34,10 @@ export function resolveDesktopBuildTarget( /** * Return the mutable preparation and artifact directories owned by one release target. * @param target - Supported Desktop target name. - * @returns Target paths plus the shared immutable download cache. + * @param environment - Build cache override; defaults to the current process. + * @returns Target paths plus shared download and package stores. */ -export function desktopTargetBuildPaths(target: DesktopAutoUpdateTarget): DesktopTargetBuildPaths +export function desktopTargetBuildPaths(target: DesktopAutoUpdateTarget, environment?: NodeJS.ProcessEnv): DesktopTargetBuildPaths /** * Return the platform and architecture of the payload one release target prepares. diff --git a/apps/desktop/scripts/desktop-build-paths.mjs b/apps/desktop/scripts/desktop-build-paths.mjs index e577b1d3fe..68a0a330a8 100644 --- a/apps/desktop/scripts/desktop-build-paths.mjs +++ b/apps/desktop/scripts/desktop-build-paths.mjs @@ -1,6 +1,6 @@ /** Resolve build-owned Desktop paths without sharing mutable state across release targets. */ -import { join, resolve } from 'node:path' +import { isAbsolute, join, resolve } from 'node:path' const APP_ROOT = resolve(import.meta.dirname, '..') const BUILD_ROOT = join(APP_ROOT, '.desktop-build') @@ -38,10 +38,13 @@ function assertSupportedTarget(target) { /** * Return the mutable preparation and artifact directories owned by one release target. * @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target - Supported Desktop target name. - * @returns {{ root: string, artifacts: string, unsignedArtifacts: string, unnotarizedArtifacts: string, runtime: string, packageSet: string, dsh: string, dshPnpm: string, electron: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string }} Target paths plus the shared immutable download cache. + * @param {NodeJS.ProcessEnv} environment Build cache override; defaults to the current process. + * @returns {{ root: string, artifacts: string, unsignedArtifacts: string, unnotarizedArtifacts: string, runtime: string, packageSet: string, dsh: string, dshPnpm: string, electron: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string, pnpmStore: string }} Target paths plus shared download and package stores. */ -export function desktopTargetBuildPaths(target) { +export function desktopTargetBuildPaths(target, environment = process.env) { assertSupportedTarget(target) + const cache = environment.DSH_DESKTOP_PACKAGE_CACHE_DIR ?? BUILD_ROOT + if (!isAbsolute(cache)) throw new Error('desktop build paths: DSH_DESKTOP_PACKAGE_CACHE_DIR must be absolute') const root = join(BUILD_ROOT, 'targets', target) const packed = join(root, 'packed') return { @@ -57,7 +60,8 @@ export function desktopTargetBuildPaths(target) { packedDsh: join(packed, 'dsh'), packedVendor: join(packed, 'vendor'), packedLandlock: join(packed, 'landlock'), - downloads: join(BUILD_ROOT, 'downloads'), + downloads: join(cache, 'downloads'), + pnpmStore: join(cache, 'pnpm-store'), } } @@ -87,7 +91,7 @@ export function resolveDesktopTargetBuildPaths( hostPlatform = process.platform, hostArch = process.arch, ) { - return desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch)) + return desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch), env) } /** diff --git a/apps/desktop/scripts/desktop-package-environment.mjs b/apps/desktop/scripts/desktop-package-environment.mjs index 32f58ce874..81e1bf5274 100644 --- a/apps/desktop/scripts/desktop-package-environment.mjs +++ b/apps/desktop/scripts/desktop-package-environment.mjs @@ -12,6 +12,7 @@ import { validateMacOSSignatureCacheDirectory } from './macos-signature-cache-di import { resolveMacOSPackageSettings } from './macos-package-settings.mjs' import { resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs' import { resolveWindowsPackageSettings } from './windows-package-settings.mjs' +import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' const APP_ROOT = fileURLToPath(new URL('..', import.meta.url)) const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_RELEASE_ID|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u @@ -134,6 +135,7 @@ function requireReadableFile(environment, name) { * @returns {void} */ export function validateDesktopPackageEnvironment(environment, target, options = {}) { + resolveDesktopTargetBuildPaths({ ...environment, DSH_DESKTOP_TARGET_PLATFORM: target.platform, DSH_DESKTOP_TARGET_ARCH: target.arch }) resolveDesktopAppId(environment) resolveNpmRegistry(environment) if (target.platform !== 'darwin' || !options.unsigned) resolveDesktopPolicyEnvironment(environment) diff --git a/apps/desktop/scripts/prepare-dsh.ts b/apps/desktop/scripts/prepare-dsh.ts index 365c5a45f2..3262fcd62a 100644 --- a/apps/desktop/scripts/prepare-dsh.ts +++ b/apps/desktop/scripts/prepare-dsh.ts @@ -39,7 +39,7 @@ const APP_ROOT = resolve(import.meta.dirname, '..') const BUILD_PATHS = resolveDesktopTargetBuildPaths() const DSH_OUTPUT_ROOT = BUILD_PATHS.dsh const BUILD_ROOT = mkdtempSync(join(tmpdir(), 'dsh-desktop-runtime-')) -const STORE_ROOT = join(BUILD_ROOT, 'store') +const STORE_ROOT = BUILD_PATHS.pnpmStore const RUNTIME_ROOT = BUILD_PATHS.runtime const PNPM_BUILD_STATE = BUILD_PATHS.dshPnpm const PACKAGE_SET_ROOT = BUILD_PATHS.packageSet @@ -83,6 +83,8 @@ function runPnpm(args: readonly string[]): Promise { `--config.registry=${registry}`, `--config.store-dir=${STORE_ROOT}`, '--config.enable-global-virtual-store=false', + '--config.verify-store-integrity=true', + '--config.package-import-method=copy', `--config.userconfig=${userConfig}`, command, ...commandArgs, diff --git a/apps/desktop/tests/desktop-build-paths.spec.ts b/apps/desktop/tests/desktop-build-paths.spec.ts index ede5c5e3d3..2fc0d09ed9 100644 --- a/apps/desktop/tests/desktop-build-paths.spec.ts +++ b/apps/desktop/tests/desktop-build-paths.spec.ts @@ -35,9 +35,10 @@ describe('desktop build paths', () => { expect(windows.runtime).toContain(join('targets', 'win-x64', 'runtime')) }) - it('shares only the immutable upstream download cache', () => { + it('shares upstream downloads and the integrity-checked package store', () => { const arm64 = desktopTargetBuildPaths('mac-arm64') const x64 = desktopTargetBuildPaths('mac-x64') + expect(arm64.pnpmStore).toBe(x64.pnpmStore) expect(arm64.downloads).toBe(x64.downloads) expect(arm64.downloads).not.toContain(`${sep}targets${sep}`) }) @@ -68,3 +69,17 @@ describe('desktop build paths', () => { expect(() => desktopTargetBuildPaths('linux-x64' as 'mac-x64')).toThrow(/unsupported target/u) }) }) + +it('keeps caches outside the disposable target directories when explicitly configured', () => { + const cache = join(process.cwd(), 'external-build-cache') + const environment = { DSH_DESKTOP_PACKAGE_CACHE_DIR: cache } + for (const target of ['mac-arm64', 'mac-x64', 'win-x64'] as const) { + const paths = desktopTargetBuildPaths(target, environment) + expect(paths.downloads).toBe(join(cache, 'downloads')) + expect(paths.pnpmStore).toBe(join(cache, 'pnpm-store')) + expect(paths.root).not.toContain(cache) + } + for (const value of ['', 'relative-cache']) { + expect(() => desktopTargetBuildPaths('mac-arm64', { DSH_DESKTOP_PACKAGE_CACHE_DIR: value })).toThrow('must be absolute') + } +}) diff --git a/apps/desktop/tests/desktop-package-environment.spec.ts b/apps/desktop/tests/desktop-package-environment.spec.ts index 6116cb718a..fdd2b00fa9 100644 --- a/apps/desktop/tests/desktop-package-environment.spec.ts +++ b/apps/desktop/tests/desktop-package-environment.spec.ts @@ -272,3 +272,8 @@ it('skips Apple credentials but still requires signing credentials for unnotariz }).toThrow(/CSC_KEY_PASSWORD/u) }) }) + +it.each(['', 'relative-cache'])('rejects invalid build cache paths during preflight (%s)', (cache) => { + expect(() => { validateDesktopPackageEnvironment({ ...RELEASE, DSH_DESKTOP_PACKAGE_CACHE_DIR: cache }, MACOS, { unsigned: true }) }) + .toThrow('DSH_DESKTOP_PACKAGE_CACHE_DIR must be absolute') +}) diff --git a/scripts/macos-package-workflow.spec.ts b/scripts/macos-package-workflow.spec.ts index fb85352d0e..8d8e4df3f7 100644 --- a/scripts/macos-package-workflow.spec.ts +++ b/scripts/macos-package-workflow.spec.ts @@ -9,6 +9,7 @@ const workflow = yaml.load(readFileSync(resolve(import.meta.dirname, '../.github concurrency: { group: string; 'cancel-in-progress': boolean } jobs: Record steps: Array<{ id?: string; name?: string; if?: string; uses?: string; run?: string; with?: Record }> }> } @@ -40,3 +41,9 @@ it('defaults to notarization and labels the explicit debugging option', () => { expect(steps.filter(step => step.run?.includes('args+=(--skip-notarization)'))).toHaveLength(2) expect(steps.find(step => step.name === 'Retain installers for the selected mode')?.with?.name).toContain('${{ steps.settings.outputs.mode }}') }) + +it('retains build inputs outside checkout and run-specific pnpm installations', () => { + const env = workflow.jobs.package!.env + expect(env.DSH_DESKTOP_PACKAGE_CACHE_DIR).toBe('${{ runner.tool_cache }}/dsh-desktop-package-cache') + expect(env.PNPM_CONFIG_STORE_DIR).toBe('${{ runner.tool_cache }}/dsh-desktop-package-cache/workspace-pnpm-store') +}) diff --git a/scripts/windows-package-workflow.spec.ts b/scripts/windows-package-workflow.spec.ts index 9be7cd8f6c..56d4e58370 100644 --- a/scripts/windows-package-workflow.spec.ts +++ b/scripts/windows-package-workflow.spec.ts @@ -61,6 +61,7 @@ it('routes manual packaging to the dedicated runner with explicit preset and sig expect(Object.keys(workflow.on)).toEqual(['workflow_dispatch']) expect(workflow.jobs.package).not.toHaveProperty('name') expect(job['runs-on']).toBe('dsh-win-package-trial') + expect(job.env.DSH_DESKTOP_PACKAGE_CACHE_DIR).toBe('${{ runner.tool_cache }}/dsh-desktop-package-cache') expect(workflow.concurrency).toEqual({ group: 'windows-desktop-package', 'cancel-in-progress': false }) expect(workflow.permissions).toEqual({ contents: 'read' }) expect(Object.keys(workflow.on.workflow_dispatch!.inputs).sort()).toEqual([ @@ -107,3 +108,25 @@ it('retains failure diagnostics and uploads only the selected successful install expect(upload.with).toMatchObject({ path: '${{ steps.retained.outputs.directory }}/*', 'if-no-files-found': 'error' }) } }) + +it.skipIf(process.platform !== 'win32')('keeps the dependency store stable across run-specific pnpm homes', () => { + const directory = mkdtempSync(resolve(tmpdir(), 'windows-package-store-')) + try { + const step = job.steps.find(entry => entry.name === 'Configure persistent pnpm store')! + expect(job.steps.indexOf(step)).toBeLessThan(job.steps.findIndex(entry => entry.name === 'Install locked dependencies')) + const script = resolve(directory, 'store.ps1') + writeFileSync(script, step.run!) + const outputs = ['first', 'second'].map((run) => { + const environmentFile = resolve(directory, run + '.env') + execFileSync('pwsh', ['-NoProfile', '-File', script], { + env: { ...process.env, GITHUB_WORKSPACE: 'D:\\runner\\_work\\repo\\repo', + PNPM_HOME: resolve(directory, run, 'pnpm'), GITHUB_ENV: environmentFile }, + stdio: 'pipe', + }) + return readFileSync(environmentFile, 'utf8').trim() + }) + expect(outputs).toEqual(['PNPM_CONFIG_STORE_DIR=D:\\.pnpm-store', 'PNPM_CONFIG_STORE_DIR=D:\\.pnpm-store']) + } finally { + rmSync(directory, { recursive: true, force: true, maxRetries: 3 }) + } +}) From fb20e72e96b697258487ac23721f738bcc0d8a58 Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:01:09 +0800 Subject: [PATCH 05/46] perf(windows): isolate package cache measurements and configure compression --- .github/workflows/windows-package.yml | 15 ++++++++ apps/desktop/.env.windows.example | 3 ++ .../windows-packaging/production.env.example | 2 + .../config/windows-packaging/test.env.example | 2 + apps/desktop/packaging/README.i18n.yaml | 16 ++++---- apps/desktop/packaging/README.md | 8 +++- apps/desktop/packaging/README.zh.md | 8 +++- .../desktop/scripts/desktop-build-paths.d.mts | 6 +-- apps/desktop/scripts/desktop-build-paths.mjs | 16 ++++---- .../scripts/desktop-package-environment.mjs | 6 +-- .../scripts/electron-builder-config.mjs | 2 +- apps/desktop/scripts/package-target.ts | 9 +++-- apps/desktop/scripts/prepare-dsh.ts | 8 +++- .../scripts/windows-package-settings.d.mts | 9 +++-- .../scripts/windows-package-settings.mjs | 15 +++++--- .../desktop/tests/desktop-build-paths.spec.ts | 37 ++++++++++--------- .../tests/desktop-package-environment.spec.ts | 36 +++++++++++++++--- .../tests/package-target-stages.spec.ts | 15 ++++++++ scripts/windows-package-workflow.spec.ts | 20 +++++++++- 19 files changed, 169 insertions(+), 64 deletions(-) diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index d2a00a7f3f..14074c157c 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -42,6 +42,11 @@ on: type: string default: '' required: false + cache_namespace: + description: '缓存测量命名空间:留空使用共享缓存;新名称用于冷缓存,再次使用同名测热缓存,不清除现有缓存' + type: string + default: '' + required: false permissions: contents: read @@ -66,6 +71,7 @@ jobs: DSH_PACKAGING_PRESET: ${{ inputs.preset }} PACKAGE_TEST_RELEASE_ID: ${{ inputs.test_release_id }} PACKAGE_SIGNING: ${{ inputs.signing }} + PACKAGE_CACHE_NAMESPACE: ${{ inputs.cache_namespace }} steps: - name: Reset packaging checkout working-directory: ${{ runner.temp }} @@ -112,9 +118,18 @@ jobs: # PNPM_HOME belongs to this run; keep downloaded packages outside its temporary directory. run: | $storeRoot = Join-Path ([IO.Path]::GetPathRoot($env:GITHUB_WORKSPACE)) '.pnpm-store' + $namespace = $env:PACKAGE_CACHE_NAMESPACE + if ($namespace) { + if ($namespace -cnotmatch '^[a-z0-9][a-z0-9-]{0,63}\z') { throw 'Cache namespace must contain 1-64 lowercase letters, digits or hyphens' } + if ($namespace -match '^(con|prn|aux|nul|com[1-9]|lpt[1-9])$') { throw 'Cache namespace cannot be a Windows device name' } + $storeRoot = Join-Path $storeRoot $namespace + "DSH_DESKTOP_PACKAGE_CACHE_DIR=$(Join-Path $env:DSH_DESKTOP_PACKAGE_CACHE_DIR $namespace)" >> $env:GITHUB_ENV + } "PNPM_CONFIG_STORE_DIR=$storeRoot" >> $env:GITHUB_ENV - name: Install locked dependencies run: | + pnpm store path + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } pnpm install --frozen-lockfile exit $LASTEXITCODE - name: Validate preset and resolve build version diff --git a/apps/desktop/.env.windows.example b/apps/desktop/.env.windows.example index 4950c70b27..9ff6ed0db2 100644 --- a/apps/desktop/.env.windows.example +++ b/apps/desktop/.env.windows.example @@ -31,6 +31,9 @@ DSH_DESKTOP_WINDOWS_TOKEN_PIN= # Cache-hit restore and verification workers (1-8). Hardware signing remains serial. DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=4 +# Installer archive compression (0-9); lower values trade size for packaging time. +DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=9 + # Optional upload configuration; packaging does not require these credentials. # DOWNLOAD_TEST_COS_BUCKET= # DOWNLOAD_TEST_COS_SECRET_ID= diff --git a/apps/desktop/config/windows-packaging/production.env.example b/apps/desktop/config/windows-packaging/production.env.example index 2e05825590..4b940a5fc1 100644 --- a/apps/desktop/config/windows-packaging/production.env.example +++ b/apps/desktop/config/windows-packaging/production.env.example @@ -5,3 +5,5 @@ DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN= DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{}' # DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR= DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=4 +# Installer archive compression (0-9). +DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=9 diff --git a/apps/desktop/config/windows-packaging/test.env.example b/apps/desktop/config/windows-packaging/test.env.example index 718481f13a..a8d5b60c3d 100644 --- a/apps/desktop/config/windows-packaging/test.env.example +++ b/apps/desktop/config/windows-packaging/test.env.example @@ -11,3 +11,5 @@ DOWNLOAD_TEST_RELEASE_ID= # Optional absolute private cache directory. Omit to retain the account default cache. # DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR= DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=4 +# Installer archive compression (0-9). +DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=9 diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index aac571f1f2..80ea8ba1b5 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -15,8 +15,8 @@ en: a6203dc2e690c7e1 zh: 07b516549b8e3328 /desktop-packaging/choose-a-build-path: - en: 447e66fb59b03fb1 - zh: a87d440407b4bde3 + en: 17c6bcdefa05a8f8 + zh: 4ee18f832276144d /desktop-packaging/release-versions: en: 3f38ab26ee195b39 zh: f00eddafa8f3b9ca @@ -24,8 +24,8 @@ en: 5b4f0f80a117f202 zh: ff05be196c181087 /desktop-packaging/windows-actions-packaging: - en: e1b101593f6f755e - zh: 121f225de399de1e + en: 79f09d4deac94041 + zh: 6e0cfbda4c2f6518 /desktop-packaging/macos-self-hosted-packaging: en: a3af9523fdc60036 zh: 5e18d4740e9b7f57 @@ -45,11 +45,11 @@ en: b42f103358d0dfd7 zh: a534164ad9eb00da /desktop-packaging/runtime-file-selection-and-installation-checks: - en: bfd89e1d139c5729 - zh: 5d6cced5462ba13e + en: 34273efbc5084038 + zh: f255e29623b6af76 /desktop-packaging/desktop-runtime-preparation: - en: a7aa3066e649ffe8 - zh: 4334637910058c09 + en: 217f4585f65fc5b5 + zh: 30f5b1a894f9ec00 /desktop-packaging/installation-and-release-verification: en: eb35eb973b796643 zh: c1c52311632b7296 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index 0255960a2c..5736ef5ce3 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -32,8 +32,6 @@ Build Desktop installers locally or through Windows or macOS Actions, choose dep Run local commands from the repository root after installing locked dependencies. Use platform-specific commands for a local build, or platform Actions for the registered self-hosted runner. Choose test or production deployment independently from signed or unsigned mode. Read [Windows signing and runner operations](../windows-signing/README.md#trust-and-isolation) before any Windows signed build or shared-runner use. Installation is separate from packaging; [publication](../publishing/README.md) is a further explicit operation. -Set `DSH_DESKTOP_PACKAGE_CACHE_DIR` to an absolute build-cache directory to retain verified downloads and the pnpm content store across checkouts. Both packaging workflows use `runner.tool_cache/dsh-desktop-package-cache`; local commands default to `.desktop-build`. Runtime preparation still creates a fresh installation and lockfile, verifies stored package integrity, and copies files before signing. Cleanup removes temporary installations, not these caches. Operators manage cache disk usage; no automatic eviction runs. - ## Release versions @@ -115,6 +113,8 @@ Select the workflow in Actions, choose the source branch, preset, signing mode a Open the completed run in Actions and download its Artifacts. `windows-x64-signed---` and `windows-x64-unsigned---` contain the corresponding EXE; `windows-package-logs--` contains packaging diagnostics. The upload steps are `Retain verified signed installer`, `Retain verified unsigned installer`, and `Retain packaging diagnostics`; their step durations and timestamped logs show upload time. These artifacts are not a Release or download-site publication. +For cache measurements, `cache_namespace` selects a retained subdirectory for both dependency stores and upstream downloads; leave it empty for normal shared caches. Use a new name for a cold sample and repeat that name for warm samples. Names contain 1–64 lowercase letters, digits or hyphens, start with a letter or digit, and cannot be Windows device names. This does not reset the separate signature cache or OS file cache; report those states and Job start-to-completion time alongside phase timings and artifact size. + ## macOS self-hosted packaging @@ -256,12 +256,16 @@ When extraction fails, the installer writes a report with the 7-Zip result and i Windows packaging fixes the 7-Zip filter to `BCJ` for compatibility with the bundled NSIS decoder. This preserves ARM64 binaries carried by dependencies in x64 installers; automatic ARM64 filtering produces entries that this decoder cannot extract. +`DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL` in the Windows dotenv or machine preset selects installer archive compression from `0` to `9`. If omitted, the existing `ELECTRON_BUILDER_COMPRESSION_LEVEL` environment override remains effective, otherwise the default is `9`. Lower levels trade installer size for packaging time; qualify the complete signed workflow and artifact size before changing a release preset. + NSIS removes its temporary extraction tree during installation, before the completion page or an automatic launch. The installed production packages remain ordinary files; startup does not extract them again. Installation still writes the complete application tree. ## Desktop runtime preparation +`DSH_DESKTOP_PACKAGE_CACHE_DIR` in the packaging process environment selects a nonempty absolute directory for shared `downloads` and `pnpm-store` caches; omission uses `.desktop-build`. Temporary installation projects, lockfiles and configuration remain separate per run. Runtime installation verifies store integrity and copies package files so later signing cannot modify cached bytes; cleanup retains these caches. Windows Actions uses `runner.tool_cache/dsh-desktop-package-cache`, while repository dependency installation uses a separate `.pnpm-store` at the workspace drive root. Both installation logs identify their store and report package reuse. A fresh runtime lockfile still resolves dependencies on every run. + Desktop [patch policy](../scripts/runtime-patch-policy.ts) explicitly classifies every workspace patch as shared, workspace-only, or runtime-only. Shared entries reuse the root patch bytes and verify their hash against the root lockfile; runtime-only entries use separate repository files without changing workspace installation. Workspace-only patches belong to build tools or dependencies already embedded in client bundles. Unclassified or stale entries, missing files, changed hashes, incompatible resolved versions, and workspace-only packages entering the runtime stop packaging. The [preparation script](../scripts/prepare-runtime-patches.ts) writes only to the temporary project; pnpm applies the selected patches and rejects application failures. Desktop dependency overrides are declared separately in the patch-policy module, not inferred from patch versions. The pi-ai constraint keeps its runtime version at the version qualified by the shared patch. Desktop still resolves a fresh runtime lockfile for each build, then installs with `--frozen-lockfile`; dependency resolution across separate builds is not yet reproducible. CLI packed-installation tests and other distribution flows retain their own configuration. diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index de9d86eb20..7e3febef7e 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -32,8 +32,6 @@ description: "在本地或 Windows/macOS Actions 中生成 Desktop 安装包 安装锁定依赖后,从仓库根目录运行本地命令。本地构建使用平台命令,已注册的自托管 runner 使用对应平台的 Actions。test/production 部署与 signed/unsigned 模式分别选择。任何 Windows 签名构建或共享 runner 使用前,先阅读 [Windows 签名与 runner 运维](../windows-signing/README.zh.md#trust-and-isolation)。安装与打包分开进行;[发布](../publishing/README.zh.md)还需要单独的显式操作。 -将 `DSH_DESKTOP_PACKAGE_CACHE_DIR` 设为绝对构建缓存目录,即可跨 checkout 保留经过校验的下载文件和 pnpm 内容存储。两个打包工作流使用 `runner.tool_cache/dsh-desktop-package-cache`;本地命令默认使用 `.desktop-build`。runtime 准备仍创建全新安装目录和锁文件、校验缓存包的完整性,并在签名前复制文件。清理只删除临时安装,不删除这些缓存。缓存磁盘用量由操作者管理,不自动淘汰。 - ## 发布版本 @@ -115,6 +113,8 @@ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-s 在 Actions 打开完成的运行并下载 Artifacts。`windows-x64-signed---` 与 `windows-x64-unsigned---` 包含对应 EXE;`windows-package-logs--` 包含打包诊断。上传步骤名为 `Retain verified signed installer`、`Retain verified unsigned installer` 和 `Retain packaging diagnostics`;步骤耗时与带时间戳的日志显示上传用时。这些产物不等于发布 Release 或下载站内容。 +测量缓存时,`cache_namespace` 为两个依赖 store 和上游下载选择保留的子目录;留空使用正常共享缓存。新名称用于冷缓存采样,再次使用同名用于热缓存采样。名称由 1–64 个小写字母、数字或连字符组成,以字母或数字开头,且不能为 Windows 设备名。这不会重置独立的签名缓存或操作系统文件缓存;应将其状态、Job 开始至完成的总耗时与阶段耗时和产物体积一并报告。 + ## macOS 自托管打包 @@ -256,12 +256,16 @@ Windows 安装器在启动时和选定目标目录后检查应用是否正在运 Windows 打包将 7-Zip 过滤器固定为 `BCJ`,以兼容内置的 NSIS 解码器。这样可以保留 x64 安装包中由依赖携带的 ARM64 二进制文件;自动 ARM64 过滤会生成该解码器无法解压的条目。 +Windows dotenv 或机器 preset 中的 `DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL` 选择 `0` 至 `9` 的安装包归档压缩等级。省略时,现有 `ELECTRON_BUILDER_COMPRESSION_LEVEL` 环境覆盖仍然生效,否则默认为 `9`。较低等级以安装包体积换取打包时间;修改发布 preset 前应验证完整签名 workflow 和产物体积。 + NSIS 在安装阶段清理临时解压目录,完成后才显示完成页或自动启动应用。已安装的生产依赖保持为普通文件;启动时不会再次解压。安装仍会写入完整的应用目录树。 ## Desktop 运行时准备 +打包进程环境中的 `DSH_DESKTOP_PACKAGE_CACHE_DIR` 为共享的 `downloads` 和 `pnpm-store` 缓存指定非空绝对目录;省略时使用 `.desktop-build`。临时安装项目、lockfile 和配置仍逐次独立创建。运行时安装校验 store 完整性并复制包文件,防止后续签名修改缓存字节;清理时保留这些缓存。Windows Actions 使用 `runner.tool_cache/dsh-desktop-package-cache`,仓库依赖安装则使用工作区盘根目录下独立的 `.pnpm-store`。两次安装日志均标明各自的 store 并报告包复用情况。每次运行仍通过新的运行时 lockfile 解析依赖。 + Desktop [补丁策略](../scripts/runtime-patch-policy.ts) 将每份工作区补丁明确归为共享、仅工作区或仅运行时。共享项复用根目录补丁字节,并对照根锁文件校验 hash;仅运行时项使用仓库内的独立文件,不改变工作区安装。仅工作区补丁用于构建工具或已嵌入客户端 bundle 的依赖。未分类或过期的条目、文件缺失、hash 改变、解析版本不兼容,以及仅工作区包进入运行时,都会阻止打包。[准备脚本](../scripts/prepare-runtime-patches.ts) 只写入临时项目;pnpm 负责应用选中的补丁,应用失败时会报错。 Desktop 依赖 overrides 在补丁策略模块中单独声明,不从补丁版本推导。pi-ai 约束使运行时版本保持在共享补丁已验证的版本。Desktop 仍会在每次构建时重新解析运行时锁文件,再通过 `--frozen-lockfile` 安装;不同构建之间的依赖解析尚不保证可复现。CLI 打包安装测试及其他交付流程保留各自的配置。 diff --git a/apps/desktop/scripts/desktop-build-paths.d.mts b/apps/desktop/scripts/desktop-build-paths.d.mts index c069dda3f7..910f3032ee 100644 --- a/apps/desktop/scripts/desktop-build-paths.d.mts +++ b/apps/desktop/scripts/desktop-build-paths.d.mts @@ -1,6 +1,6 @@ import type { DesktopAutoUpdateTarget } from './desktop-auto-update-environment.mjs' -/** Mutable target directories plus shared download and package stores. */ +/** Mutable target directories plus shared download and package caches. */ export interface DesktopTargetBuildPaths { readonly root: string readonly artifacts: string @@ -34,8 +34,8 @@ export function resolveDesktopBuildTarget( /** * Return the mutable preparation and artifact directories owned by one release target. * @param target - Supported Desktop target name. - * @param environment - Build cache override; defaults to the current process. - * @returns Target paths plus shared download and package stores. + * @param environment - Packaging environment with an optional absolute cache directory. + * @returns Target paths plus shared download and package caches. */ export function desktopTargetBuildPaths(target: DesktopAutoUpdateTarget, environment?: NodeJS.ProcessEnv): DesktopTargetBuildPaths diff --git a/apps/desktop/scripts/desktop-build-paths.mjs b/apps/desktop/scripts/desktop-build-paths.mjs index 68a0a330a8..bf2361a695 100644 --- a/apps/desktop/scripts/desktop-build-paths.mjs +++ b/apps/desktop/scripts/desktop-build-paths.mjs @@ -1,4 +1,4 @@ -/** Resolve build-owned Desktop paths without sharing mutable state across release targets. */ +/** Resolve target-owned Desktop outputs and shared package caches. */ import { isAbsolute, join, resolve } from 'node:path' @@ -38,13 +38,15 @@ function assertSupportedTarget(target) { /** * Return the mutable preparation and artifact directories owned by one release target. * @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target - Supported Desktop target name. - * @param {NodeJS.ProcessEnv} environment Build cache override; defaults to the current process. - * @returns {{ root: string, artifacts: string, unsignedArtifacts: string, unnotarizedArtifacts: string, runtime: string, packageSet: string, dsh: string, dshPnpm: string, electron: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string, pnpmStore: string }} Target paths plus shared download and package stores. + * @param {NodeJS.ProcessEnv} environment - Packaging environment with an optional absolute cache directory. + * @returns {{ root: string, artifacts: string, unsignedArtifacts: string, unnotarizedArtifacts: string, runtime: string, packageSet: string, dsh: string, dshPnpm: string, electron: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string, pnpmStore: string }} Target paths plus shared download and package caches. */ export function desktopTargetBuildPaths(target, environment = process.env) { assertSupportedTarget(target) - const cache = environment.DSH_DESKTOP_PACKAGE_CACHE_DIR ?? BUILD_ROOT - if (!isAbsolute(cache)) throw new Error('desktop build paths: DSH_DESKTOP_PACKAGE_CACHE_DIR must be absolute') + const cacheRoot = environment.DSH_DESKTOP_PACKAGE_CACHE_DIR ?? BUILD_ROOT + if (!cacheRoot.trim() || !isAbsolute(cacheRoot) || cacheRoot.includes('\0')) { + throw new Error('desktop package: DSH_DESKTOP_PACKAGE_CACHE_DIR must be a nonempty absolute directory') + } const root = join(BUILD_ROOT, 'targets', target) const packed = join(root, 'packed') return { @@ -60,8 +62,8 @@ export function desktopTargetBuildPaths(target, environment = process.env) { packedDsh: join(packed, 'dsh'), packedVendor: join(packed, 'vendor'), packedLandlock: join(packed, 'landlock'), - downloads: join(cache, 'downloads'), - pnpmStore: join(cache, 'pnpm-store'), + downloads: join(cacheRoot, 'downloads'), + pnpmStore: join(cacheRoot, 'pnpm-store'), } } diff --git a/apps/desktop/scripts/desktop-package-environment.mjs b/apps/desktop/scripts/desktop-package-environment.mjs index 81e1bf5274..ff9ba89bde 100644 --- a/apps/desktop/scripts/desktop-package-environment.mjs +++ b/apps/desktop/scripts/desktop-package-environment.mjs @@ -16,7 +16,7 @@ import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' const APP_ROOT = fileURLToPath(new URL('..', import.meta.url)) const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_RELEASE_ID|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|SIGNATURE_CACHE_CONCURRENCY|PACK_CONCURRENCY)$/u +const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR|SIGNATURE_CACHE_CONCURRENCY|PACK_CONCURRENCY|COMPRESSION_LEVEL)$/u const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|SIGNATURE_CACHE_DIR|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'] @@ -54,7 +54,7 @@ export function loadDesktopPackageEnvironment(platform, environment = process.en } const platformSetting = platform === 'win32' ? WINDOWS_SETTING : MACOS_SETTING const publicPlatformSetting = platform === 'win32' - ? /^DSH_DESKTOP_WINDOWS_(?:SIGNATURE_CACHE_(?:DIR|CONCURRENCY)|PACK_CONCURRENCY)$/u + ? /^DSH_DESKTOP_WINDOWS_(?:SIGNATURE_CACHE_(?:DIR|CONCURRENCY)|PACK_CONCURRENCY|COMPRESSION_LEVEL)$/u : /^DSH_DESKTOP_MACOS_(?:SIGNATURE_CACHE_DIR|PACK_CONCURRENCY|DOWNLOAD_PROXY|NOTARIZATION_PROXY)$/u const settings = readSettings(path, name => preset === undefined ? SHARED_SETTING.test(name) || platformSetting.test(name) @@ -135,7 +135,7 @@ function requireReadableFile(environment, name) { * @returns {void} */ export function validateDesktopPackageEnvironment(environment, target, options = {}) { - resolveDesktopTargetBuildPaths({ ...environment, DSH_DESKTOP_TARGET_PLATFORM: target.platform, DSH_DESKTOP_TARGET_ARCH: target.arch }) + resolveDesktopTargetBuildPaths(environment, target.platform, target.arch) resolveDesktopAppId(environment) resolveNpmRegistry(environment) if (target.platform !== 'darwin' || !options.unsigned) resolveDesktopPolicyEnvironment(environment) diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs index 7dc39ff326..992150e523 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -69,7 +69,7 @@ export function createElectronBuilderConfig( if (resolvedPlatform === 'win32') installWindowsDirectoryInstaller() const macOSSigning = packagesMacOS && !unsigned ? resolveMacOSSigningEnvironment(env) : undefined if (packagesMacOS && !unsigned && !skipNotarization) resolveMacOSNotarizationEnvironment(env) - const buildPaths = desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch)) + const buildPaths = desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch), env) const output = desktopArtifactOutput(buildPaths, { unsigned, skipNotarization }) let primaryRuntimeDestination let dshDestination diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index c3dd201989..401fafa771 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -328,7 +328,7 @@ async function resolveRequestedBuildVersion( const requested = invocation.requestedBuildVersion if (requested === undefined) return productVersion if (requested !== AUTOMATIC_BUILD_VERSION) return validateDesktopBuildVersion(requested, productVersion) - const paths = desktopTargetBuildPaths(invocation.target.name) + const paths = desktopTargetBuildPaths(invocation.target.name, environment) return suggestDesktopBuildVersion({ productVersion, target: invocation.target.name, // Machine presets own packaging, not upload credentials; local auto numbering uses existing artifacts. @@ -400,7 +400,7 @@ async function main(): Promise { run.finish(success) } if (success && target.platform === 'darwin' && !invocation.directory && !invocation.prepareOnly) { - const paths = desktopTargetBuildPaths(target.name) + const paths = desktopTargetBuildPaths(target.name, environment) const output = desktopArtifactOutput(paths, invocation) const filename = `deepseek-harness-${buildVersion}-mac-${target.arch}${output.suffix}.dmg` console.log(`DMG: ${join(output.directory, filename)}`) @@ -426,7 +426,7 @@ export async function packageTarget( const mac = target.platform === 'darwin' ? resolveMacOSPackageSettings(environment) : undefined const packConcurrency = mac?.packConcurrency ?? resolveWindowsPackageSettings(environment).packConcurrency const packArguments = ['--concurrency', String(packConcurrency)] - const buildPaths = desktopTargetBuildPaths(target.name) + const buildPaths = desktopTargetBuildPaths(target.name, environment) const releaseRecordPath = join(buildPaths.artifacts, desktopBuildRecordFilename(target.name)) if (!invocation.prepareOnly && !invocation.unsigned && !invocation.skipNotarization) { rmSync(releaseRecordPath, { force: true }) @@ -443,6 +443,9 @@ export async function packageTarget( } const downloadEnv = macOSDownloadEnvironment(targetEnv, mac?.downloadProxy) const electronBuilderEnv = desktopElectronBuilderEnvironment(downloadEnv, invocation.unsigned) + if (target.platform === 'win32') { + electronBuilderEnv.ELECTRON_BUILDER_COMPRESSION_LEVEL = String(resolveWindowsPackageSettings(environment).compressionLevel) + } for (const name of WINDOWS_SIGNING_ENV_NAMES) { if (!invocation.unsigned && environment[name] !== undefined) electronBuilderEnv[name] = environment[name] } diff --git a/apps/desktop/scripts/prepare-dsh.ts b/apps/desktop/scripts/prepare-dsh.ts index 3262fcd62a..5c026e81bf 100644 --- a/apps/desktop/scripts/prepare-dsh.ts +++ b/apps/desktop/scripts/prepare-dsh.ts @@ -3,6 +3,7 @@ import { resolveMacOSSignatureCacheDirectory } from './macos-signature-cache-directory.mjs' import { packagingStep } from './packaging-step.mjs' import { spawn } from 'node:child_process' +import { createHash } from 'node:crypto' import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { delimiter, join, relative, resolve } from 'node:path' @@ -82,9 +83,9 @@ function runPnpm(args: readonly string[]): Promise { PNPM, `--config.registry=${registry}`, `--config.store-dir=${STORE_ROOT}`, - '--config.enable-global-virtual-store=false', '--config.verify-store-integrity=true', '--config.package-import-method=copy', + '--config.enable-global-virtual-store=false', `--config.userconfig=${userConfig}`, command, ...commandArgs, @@ -115,6 +116,7 @@ function runPnpm(args: readonly string[]): Promise { async function main(): Promise { try { + console.log(`desktop runtime: pnpm store ${STORE_ROOT}`) 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 }) @@ -129,8 +131,10 @@ async function main(): Promise { }) await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:lockfile', () => runPnpm(['install', '--lockfile-only'])) verifyRuntimePatches(BUILD_ROOT, patches) + const lockfile = readFileSync(join(BUILD_ROOT, 'pnpm-lock.yaml'), 'utf8') + console.log(`desktop runtime: lockfile sha256 ${createHash('sha256').update(lockfile).digest('hex')}`) verifyDesktopCoreLockfile( - readFileSync(join(BUILD_ROOT, 'pnpm-lock.yaml'), 'utf8'), + lockfile, readDesktopCorePackageSet(BUILD_ROOT, release.version), ) await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:install', () => runPnpm(['install', '--prod', '--frozen-lockfile', '--trust-lockfile'])) diff --git a/apps/desktop/scripts/windows-package-settings.d.mts b/apps/desktop/scripts/windows-package-settings.d.mts index cef8b8aa85..1472cecef3 100644 --- a/apps/desktop/scripts/windows-package-settings.d.mts +++ b/apps/desktop/scripts/windows-package-settings.d.mts @@ -1,6 +1,7 @@ /** - * Validate file-owned Windows packaging and cache worker limits before packaging starts. - * @param environment Windows packaging settings loaded from .env.windows. - * @returns Cache workers from one to eight and a positive integer pack limit; both default to four. Invalid values throw. + * Validate worker limits and compression before packaging starts. + * File-owned compression takes precedence over the existing electron-builder environment override. + * @param environment Loaded packaging settings and inherited build environment. + * @returns Cache workers from one to eight and a positive integer pack limit, both defaulting to four; archive compression from zero to nine, defaulting to nine. Invalid values throw. */ -export function resolveWindowsPackageSettings(environment: NodeJS.ProcessEnv): { signatureCacheConcurrency: number, packConcurrency: number } +export function resolveWindowsPackageSettings(environment: NodeJS.ProcessEnv): { signatureCacheConcurrency: number, packConcurrency: number, compressionLevel: number } diff --git a/apps/desktop/scripts/windows-package-settings.mjs b/apps/desktop/scripts/windows-package-settings.mjs index cd78e49756..1efe52feca 100644 --- a/apps/desktop/scripts/windows-package-settings.mjs +++ b/apps/desktop/scripts/windows-package-settings.mjs @@ -1,9 +1,10 @@ -/** Resolve file-owned Windows packaging and signature-cache concurrency. */ +/** Resolve Windows packaging concurrency and archive compression. */ /** - * Validate packaging and cache worker limits without accessing signing hardware. - * @param {NodeJS.ProcessEnv} environment File-owned Windows packaging settings. - * @returns {{signatureCacheConcurrency: number, packConcurrency: number}} Worker limits, each defaulting to four when omitted. + * Validate worker limits and compression without accessing signing hardware. + * File-owned compression takes precedence over the existing electron-builder environment override. + * @param {NodeJS.ProcessEnv} environment Loaded packaging settings and inherited build environment. + * @returns {{signatureCacheConcurrency: number, packConcurrency: number, compressionLevel: number}} Worker limits defaulting to four and archive compression defaulting to nine. */ export function resolveWindowsPackageSettings(environment) { const value = environment.DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY ?? '4' @@ -14,5 +15,9 @@ export function resolveWindowsPackageSettings(environment) { if (!/^[1-9]\d*$/u.test(pack) || !Number.isSafeInteger(Number(pack))) { throw new Error('desktop package: DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY must be a positive integer') } - return { signatureCacheConcurrency: Number(value), packConcurrency: Number(pack) } + const compression = environment.DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL ?? environment.ELECTRON_BUILDER_COMPRESSION_LEVEL ?? '9' + if (!/^[0-9]$/u.test(compression)) { + throw new Error('desktop package: DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL must be an integer from 0 to 9') + } + return { signatureCacheConcurrency: Number(value), packConcurrency: Number(pack), compressionLevel: Number(compression) } } diff --git a/apps/desktop/tests/desktop-build-paths.spec.ts b/apps/desktop/tests/desktop-build-paths.spec.ts index 2fc0d09ed9..c22eadbbe9 100644 --- a/apps/desktop/tests/desktop-build-paths.spec.ts +++ b/apps/desktop/tests/desktop-build-paths.spec.ts @@ -1,10 +1,11 @@ -import { join, sep } from 'node:path' +import { join, resolve, sep } from 'node:path' import { describe, expect, it } from 'vitest' import { desktopTargetBuildPaths, desktopTargetPlatform, developmentRuntimeDirectory, resolveDesktopBuildTarget, + resolveDesktopTargetBuildPaths, } from '../scripts/desktop-build-paths.mjs' describe('desktop build paths', () => { @@ -35,12 +36,28 @@ describe('desktop build paths', () => { expect(windows.runtime).toContain(join('targets', 'win-x64', 'runtime')) }) - it('shares upstream downloads and the integrity-checked package store', () => { + it('shares download and package caches outside target output directories', () => { const arm64 = desktopTargetBuildPaths('mac-arm64') const x64 = desktopTargetBuildPaths('mac-x64') - expect(arm64.pnpmStore).toBe(x64.pnpmStore) expect(arm64.downloads).toBe(x64.downloads) expect(arm64.downloads).not.toContain(`${sep}targets${sep}`) + expect(arm64.pnpmStore).toBe(x64.pnpmStore) + expect(arm64.pnpmStore).not.toContain(`${sep}targets${sep}`) + }) + + it('retains target outputs while relocating caches through the supplied environment', () => { + const cache = resolve('external-package-cache') + const environment = { DSH_DESKTOP_PACKAGE_CACHE_DIR: cache } + const defaults = desktopTargetBuildPaths('win-x64', {}) + const paths = resolveDesktopTargetBuildPaths(environment, 'win32', 'x64') + expect(paths).toEqual({ ...defaults, downloads: join(cache, 'downloads'), pnpmStore: join(cache, 'pnpm-store') }) + expect(desktopTargetBuildPaths('mac-arm64', environment).pnpmStore).toBe(paths.pnpmStore) + expect(defaults.pnpmStore).toBe(resolve(defaults.root, '../..', 'pnpm-store')) + }) + + it.each(['', ' ', 'relative/cache', './cache', 'cache\0invalid'])('rejects invalid cache directory %j', (directory) => { + expect(() => desktopTargetBuildPaths('win-x64', { DSH_DESKTOP_PACKAGE_CACHE_DIR: directory })) + .toThrow('DSH_DESKTOP_PACKAGE_CACHE_DIR') }) it('resolves the development primary runtime from the build target rather than the host architecture', () => { @@ -69,17 +86,3 @@ describe('desktop build paths', () => { expect(() => desktopTargetBuildPaths('linux-x64' as 'mac-x64')).toThrow(/unsupported target/u) }) }) - -it('keeps caches outside the disposable target directories when explicitly configured', () => { - const cache = join(process.cwd(), 'external-build-cache') - const environment = { DSH_DESKTOP_PACKAGE_CACHE_DIR: cache } - for (const target of ['mac-arm64', 'mac-x64', 'win-x64'] as const) { - const paths = desktopTargetBuildPaths(target, environment) - expect(paths.downloads).toBe(join(cache, 'downloads')) - expect(paths.pnpmStore).toBe(join(cache, 'pnpm-store')) - expect(paths.root).not.toContain(cache) - } - for (const value of ['', 'relative-cache']) { - expect(() => desktopTargetBuildPaths('mac-arm64', { DSH_DESKTOP_PACKAGE_CACHE_DIR: value })).toThrow('must be absolute') - } -}) diff --git a/apps/desktop/tests/desktop-package-environment.spec.ts b/apps/desktop/tests/desktop-package-environment.spec.ts index fdd2b00fa9..9a359d3529 100644 --- a/apps/desktop/tests/desktop-package-environment.spec.ts +++ b/apps/desktop/tests/desktop-package-environment.spec.ts @@ -23,6 +23,37 @@ async function withDirectory(action: (directory: string) => Promise): Prom } describe('Desktop local packaging configuration', () => { + it('loads file-owned compression and defaults to the existing Windows archive level', async () => { + expect(resolveWindowsPackageSettings({}).compressionLevel).toBe(9) + await withDirectory(async (directory) => { + await writeFile(join(directory, '.env.windows'), 'DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=3\n') + const selected = loadDesktopPackageEnvironment('win32', { DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL: '0' }, directory) + expect(resolveWindowsPackageSettings(selected).compressionLevel).toBe(3) + }) + }) + + it.each(['', '-1', '10', '3.5', '03', 'fast'])('rejects invalid compression %j before preparation', (value) => { + const selected = { ...RELEASE, DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL: value } + expect(() => { validateDesktopPackageEnvironment(selected, WINDOWS, { unsigned: true }) }) + .toThrow('COMPRESSION_LEVEL') + }) + + it.each(['', ' ', 'relative/cache'])('rejects invalid package caches before any signing mode %j', (directory) => { + for (const target of [WINDOWS, MACOS]) { + for (const options of [{ unsigned: true }, { prepareOnly: true }, {}]) { + expect(() => { validateDesktopPackageEnvironment({ DSH_DESKTOP_PACKAGE_CACHE_DIR: directory }, target, options) }) + .toThrow('DSH_DESKTOP_PACKAGE_CACHE_DIR') + } + } + }) + + it('preserves the machine package cache when loading a release file', async () => { + await withDirectory(async (directory) => { + await writeFile(join(directory, '.env.windows'), '') + expect(loadDesktopPackageEnvironment('win32', { DSH_DESKTOP_PACKAGE_CACHE_DIR: directory }, directory)) + .toEqual({ DSH_DESKTOP_PACKAGE_CACHE_DIR: directory }) + }) + }) it('selects macOS local defaults while excluding inherited release settings and retaining build networking', () => { const parent = { PATH: 'build-tools', HTTPS_PROXY: 'http://proxy.example:8080', DSH_DESKTOP_NPM_REGISTRY: 'https://registry.npmmirror.com', @@ -272,8 +303,3 @@ it('skips Apple credentials but still requires signing credentials for unnotariz }).toThrow(/CSC_KEY_PASSWORD/u) }) }) - -it.each(['', 'relative-cache'])('rejects invalid build cache paths during preflight (%s)', (cache) => { - expect(() => { validateDesktopPackageEnvironment({ ...RELEASE, DSH_DESKTOP_PACKAGE_CACHE_DIR: cache }, MACOS, { unsigned: true }) }) - .toThrow('DSH_DESKTOP_PACKAGE_CACHE_DIR must be absolute') -}) diff --git a/apps/desktop/tests/package-target-stages.spec.ts b/apps/desktop/tests/package-target-stages.spec.ts index 9ca7e94e05..c8e90de598 100644 --- a/apps/desktop/tests/package-target-stages.spec.ts +++ b/apps/desktop/tests/package-target-stages.spec.ts @@ -1,4 +1,5 @@ import { writeFileSync } from 'node:fs' +import { resolve } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' import { packageTarget, parseDesktopPackageInvocation } from '../scripts/package-target.ts' import { withMacOSNotarizationProxy } from '../scripts/macos-notarization-proxy.ts' @@ -43,6 +44,20 @@ function supervisor(failure?: string) { return { run, stages } } +it.each([undefined, '3'])('passes the package cache and selected compression to their subprocesses (%s)', async (level) => { + const { run } = supervisor() + const cache = resolve('fixture-package-cache') + await packageTarget(parseDesktopPackageInvocation(['win-x64'], 'win32', 'x64'), { + ...environment, DSH_DESKTOP_PACKAGE_CACHE_DIR: cache, + ...(level === undefined ? {} : { DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL: level }), + ELECTRON_BUILDER_COMPRESSION_LEVEL: '0', + }, run) + const prepare = run.run.mock.calls.find(([stage]) => stage.startsWith('run prepare:dsh'))! + expect(prepare[3].env.DSH_DESKTOP_PACKAGE_CACHE_DIR).toBe(cache) + const builder = run.run.mock.calls.find(([stage]) => stage.startsWith('exec electron-builder'))! + expect(builder[3].env.ELECTRON_BUILDER_COMPRESSION_LEVEL).toBe(level ?? '0') +}) + it.each([undefined, '2'])('uses bounded Windows packing while retaining signing and smoke (workers=%s)', async (workers) => { const { run, stages } = supervisor() const settings = workers === undefined ? environment : { ...environment, DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY: workers } diff --git a/scripts/windows-package-workflow.spec.ts b/scripts/windows-package-workflow.spec.ts index 56d4e58370..d3df254235 100644 --- a/scripts/windows-package-workflow.spec.ts +++ b/scripts/windows-package-workflow.spec.ts @@ -65,7 +65,7 @@ it('routes manual packaging to the dedicated runner with explicit preset and sig expect(workflow.concurrency).toEqual({ group: 'windows-desktop-package', 'cancel-in-progress': false }) expect(workflow.permissions).toEqual({ contents: 'read' }) expect(Object.keys(workflow.on.workflow_dispatch!.inputs).sort()).toEqual([ - 'build_version', 'preset', 'signing', 'source_ref', 'test_release_id', 'update_channel', 'version_mode', + 'build_version', 'cache_namespace', 'preset', 'signing', 'source_ref', 'test_release_id', 'update_channel', 'version_mode', ]) expect(workflow.on.workflow_dispatch!.inputs.signing!.default).toBe('signed') expect(workflow.on.workflow_dispatch!.inputs.build_version!.required).toBe(false) @@ -120,12 +120,28 @@ it.skipIf(process.platform !== 'win32')('keeps the dependency store stable acros const environmentFile = resolve(directory, run + '.env') execFileSync('pwsh', ['-NoProfile', '-File', script], { env: { ...process.env, GITHUB_WORKSPACE: 'D:\\runner\\_work\\repo\\repo', - PNPM_HOME: resolve(directory, run, 'pnpm'), GITHUB_ENV: environmentFile }, + PACKAGE_CACHE_NAMESPACE: '', PNPM_HOME: resolve(directory, run, 'pnpm'), GITHUB_ENV: environmentFile }, stdio: 'pipe', }) return readFileSync(environmentFile, 'utf8').trim() }) expect(outputs).toEqual(['PNPM_CONFIG_STORE_DIR=D:\\.pnpm-store', 'PNPM_CONFIG_STORE_DIR=D:\\.pnpm-store']) + const runNamespace = (namespace: string) => { + const output = resolve(directory, 'namespace.env') + rmSync(output, { force: true }) + execFileSync('pwsh', ['-NoProfile', '-File', script], { + env: { ...process.env, GITHUB_WORKSPACE: 'D:\\runner\\_work\\repo\\repo', + PACKAGE_CACHE_NAMESPACE: namespace, DSH_DESKTOP_PACKAGE_CACHE_DIR: 'D:\\tool-cache\\desktop', GITHUB_ENV: output }, + stdio: 'pipe', + }) + return readFileSync(output, 'utf8').trim().split(/\r?\n/u) + } + expect(runNamespace('perf-1')).toEqual([ + 'DSH_DESKTOP_PACKAGE_CACHE_DIR=D:\\tool-cache\\desktop\\perf-1', 'PNPM_CONFIG_STORE_DIR=D:\\.pnpm-store\\perf-1', + ]) + for (const invalid of ['../escape', 'Upper', 'has space', 'con', 'perf\n', 'a'.repeat(65)]) { + expect(() => runNamespace(invalid)).toThrow() + } } finally { rmSync(directory, { recursive: true, force: true, maxRetries: 3 }) } From a247c4c04a6821c6343e715abbf714258ed6653c Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:04:24 +0800 Subject: [PATCH 06/46] fix(ci): resolve package caches after runner allocation --- .github/workflows/macos-package.yml | 8 ++++++-- .github/workflows/windows-package.yml | 6 ++++-- scripts/macos-package-workflow.spec.ts | 19 ++++++++++++++++--- scripts/windows-package-workflow.spec.ts | 11 ++++++++--- 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/.github/workflows/macos-package.yml b/.github/workflows/macos-package.yml index 7e533fa323..a4ff109c08 100644 --- a/.github/workflows/macos-package.yml +++ b/.github/workflows/macos-package.yml @@ -62,8 +62,6 @@ jobs: PACKAGE_VERSION_MODE: ${{ inputs.version_mode }} DSH_DESKTOP_UPDATE_CHANNEL: ${{ inputs.update_channel }} DSH_TELEMETRY_DISABLED: '1' - DSH_DESKTOP_PACKAGE_CACHE_DIR: ${{ runner.tool_cache }}/dsh-desktop-package-cache - PNPM_CONFIG_STORE_DIR: ${{ runner.tool_cache }}/dsh-desktop-package-cache/workspace-pnpm-store DSH_PACKAGING_PRESET: ${{ inputs.preset }} PACKAGE_TEST_RELEASE_ID: ${{ inputs.test_release_id }} steps: @@ -97,6 +95,12 @@ jobs: - uses: actions/setup-node@v7.0.0 with: node-version: '24' + - name: Configure persistent package caches + env: + DSH_DESKTOP_PACKAGE_CACHE_DIR: ${{ runner.tool_cache }}/dsh-desktop-package-cache + run: | + echo "DSH_DESKTOP_PACKAGE_CACHE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR" >> "$GITHUB_ENV" + echo "PNPM_CONFIG_STORE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR/workspace-pnpm-store" >> "$GITHUB_ENV" - name: Install locked dependencies run: pnpm install --frozen-lockfile - name: Validate preset and resolve build version diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index 14074c157c..70ed365682 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -67,7 +67,6 @@ jobs: PACKAGE_VERSION_MODE: ${{ inputs.version_mode }} DSH_DESKTOP_UPDATE_CHANNEL: ${{ inputs.update_channel }} DSH_TELEMETRY_DISABLED: '1' - DSH_DESKTOP_PACKAGE_CACHE_DIR: ${{ runner.tool_cache }}/dsh-desktop-package-cache DSH_PACKAGING_PRESET: ${{ inputs.preset }} PACKAGE_TEST_RELEASE_ID: ${{ inputs.test_release_id }} PACKAGE_SIGNING: ${{ inputs.signing }} @@ -116,6 +115,8 @@ jobs: node-version: '24' - name: Configure persistent pnpm store # PNPM_HOME belongs to this run; keep downloaded packages outside its temporary directory. + env: + DSH_DESKTOP_PACKAGE_CACHE_DIR: ${{ runner.tool_cache }}/dsh-desktop-package-cache run: | $storeRoot = Join-Path ([IO.Path]::GetPathRoot($env:GITHUB_WORKSPACE)) '.pnpm-store' $namespace = $env:PACKAGE_CACHE_NAMESPACE @@ -123,8 +124,9 @@ jobs: if ($namespace -cnotmatch '^[a-z0-9][a-z0-9-]{0,63}\z') { throw 'Cache namespace must contain 1-64 lowercase letters, digits or hyphens' } if ($namespace -match '^(con|prn|aux|nul|com[1-9]|lpt[1-9])$') { throw 'Cache namespace cannot be a Windows device name' } $storeRoot = Join-Path $storeRoot $namespace - "DSH_DESKTOP_PACKAGE_CACHE_DIR=$(Join-Path $env:DSH_DESKTOP_PACKAGE_CACHE_DIR $namespace)" >> $env:GITHUB_ENV + $env:DSH_DESKTOP_PACKAGE_CACHE_DIR = Join-Path $env:DSH_DESKTOP_PACKAGE_CACHE_DIR $namespace } + "DSH_DESKTOP_PACKAGE_CACHE_DIR=$env:DSH_DESKTOP_PACKAGE_CACHE_DIR" >> $env:GITHUB_ENV "PNPM_CONFIG_STORE_DIR=$storeRoot" >> $env:GITHUB_ENV - name: Install locked dependencies run: | diff --git a/scripts/macos-package-workflow.spec.ts b/scripts/macos-package-workflow.spec.ts index 8d8e4df3f7..60049420e2 100644 --- a/scripts/macos-package-workflow.spec.ts +++ b/scripts/macos-package-workflow.spec.ts @@ -10,7 +10,15 @@ const workflow = yaml.load(readFileSync(resolve(import.meta.dirname, '../.github jobs: Record - steps: Array<{ id?: string; name?: string; if?: string; uses?: string; run?: string; with?: Record }> + steps: Array<{ + id?: string + name?: string + if?: string + uses?: string + run?: string + env?: Record + with?: Record + }> }> } @@ -44,6 +52,11 @@ it('defaults to notarization and labels the explicit debugging option', () => { it('retains build inputs outside checkout and run-specific pnpm installations', () => { const env = workflow.jobs.package!.env - expect(env.DSH_DESKTOP_PACKAGE_CACHE_DIR).toBe('${{ runner.tool_cache }}/dsh-desktop-package-cache') - expect(env.PNPM_CONFIG_STORE_DIR).toBe('${{ runner.tool_cache }}/dsh-desktop-package-cache/workspace-pnpm-store') + expect(JSON.stringify(env)).not.toContain('runner.') + const steps = workflow.jobs.package!.steps + const cache = steps.find(step => step.name === 'Configure persistent package caches')! + expect(cache.env?.DSH_DESKTOP_PACKAGE_CACHE_DIR).toBe('${{ runner.tool_cache }}/dsh-desktop-package-cache') + expect(cache.run).toContain('PNPM_CONFIG_STORE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR/workspace-pnpm-store') + expect(cache.run).toContain('DSH_DESKTOP_PACKAGE_CACHE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR') + expect(steps.indexOf(cache)).toBeLessThan(steps.findIndex(step => step.name === 'Install locked dependencies')) }) diff --git a/scripts/windows-package-workflow.spec.ts b/scripts/windows-package-workflow.spec.ts index d3df254235..395fe48a4e 100644 --- a/scripts/windows-package-workflow.spec.ts +++ b/scripts/windows-package-workflow.spec.ts @@ -61,7 +61,9 @@ it('routes manual packaging to the dedicated runner with explicit preset and sig expect(Object.keys(workflow.on)).toEqual(['workflow_dispatch']) expect(workflow.jobs.package).not.toHaveProperty('name') expect(job['runs-on']).toBe('dsh-win-package-trial') - expect(job.env.DSH_DESKTOP_PACKAGE_CACHE_DIR).toBe('${{ runner.tool_cache }}/dsh-desktop-package-cache') + expect(JSON.stringify(job.env)).not.toContain('runner.') + expect(job.steps.find(step => step.name === 'Configure persistent pnpm store')?.env?.DSH_DESKTOP_PACKAGE_CACHE_DIR) + .toBe('${{ runner.tool_cache }}/dsh-desktop-package-cache') expect(workflow.concurrency).toEqual({ group: 'windows-desktop-package', 'cancel-in-progress': false }) expect(workflow.permissions).toEqual({ contents: 'read' }) expect(Object.keys(workflow.on.workflow_dispatch!.inputs).sort()).toEqual([ @@ -120,12 +122,15 @@ it.skipIf(process.platform !== 'win32')('keeps the dependency store stable acros const environmentFile = resolve(directory, run + '.env') execFileSync('pwsh', ['-NoProfile', '-File', script], { env: { ...process.env, GITHUB_WORKSPACE: 'D:\\runner\\_work\\repo\\repo', - PACKAGE_CACHE_NAMESPACE: '', PNPM_HOME: resolve(directory, run, 'pnpm'), GITHUB_ENV: environmentFile }, + PACKAGE_CACHE_NAMESPACE: '', DSH_DESKTOP_PACKAGE_CACHE_DIR: 'D:\\tool-cache\\desktop', + PNPM_HOME: resolve(directory, run, 'pnpm'), GITHUB_ENV: environmentFile }, stdio: 'pipe', }) return readFileSync(environmentFile, 'utf8').trim() }) - expect(outputs).toEqual(['PNPM_CONFIG_STORE_DIR=D:\\.pnpm-store', 'PNPM_CONFIG_STORE_DIR=D:\\.pnpm-store']) + expect(outputs.map(output => output.split(/\r?\n/u))).toEqual(Array.from({ length: 2 }, () => [ + 'DSH_DESKTOP_PACKAGE_CACHE_DIR=D:\\tool-cache\\desktop', 'PNPM_CONFIG_STORE_DIR=D:\\.pnpm-store', + ])) const runNamespace = (namespace: string) => { const output = resolve(directory, 'namespace.env') rmSync(output, { force: true }) From b7f46b5739a24bc55c3368e5961a6292f51cec95 Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:38:24 +0800 Subject: [PATCH 07/46] perf(build): overlap client compilation with host bundling --- docs/development.i18n.yaml | 4 ++-- docs/development.md | 2 +- docs/development.zh.md | 2 +- scripts/build.spec.ts | 6 +++--- scripts/compile-referenced-projects.ts | 8 +++++--- 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index f7cd583405..5b3ee16e81 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -21,8 +21,8 @@ en: 3221d1076554001f zh: 29f166be6a5c04d9 /development-guide/contributor-reference/typescript-project-layout: - en: 1fe7b3be73b3f8f6 - zh: 9458d1b2908e4c12 + en: 98394d8afa49c7c2 + zh: 12e2b6585f370259 /development-guide/contributor-reference/environment-variables: en: dba2c72ee6ffa930 zh: b65c27fbb9129ec8 diff --git a/docs/development.md b/docs/development.md index c75f6647fe..01f58403ea 100644 --- a/docs/development.md +++ b/docs/development.md @@ -74,7 +74,7 @@ Host and Client stay two aggregate programs because both sides declaration-merge Six packages split Host and Client tsconfigs: `api/remotes`, `api/gateway`, `api/session-controller`, `api/workspace-controller`, `client/connection`, and `session-query/session-log-export`. `api/remotes`' Host entry participates in the Host Typert graph while its Client entry imports generated `/remote` declarations; `session-log-export` keeps Node archive production out of its browser controller. Each split package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf; it discovers split packages from the presence of both leaf configs, so a new split joins the gate automatically. The [`api-remotes` README](../packages/api/remotes/README.md) and [`session-log-export` README](../packages/session-query/session-log-export/README.md) explain their splits. -`pnpm run build:artifacts` emits every artifact in the generated dependency order, checking and compiling only the package references of each compiler face: +`pnpm run build:artifacts` emits every artifact, checking and compiling only the package references of each compiler face. Client compilation starts after Host compilation and overlaps Host and Desktop bundling; Client bundling waits for both Client compilation and Desktop bundling. The equivalent serial order is: ```sh pnpm run build:native-system diff --git a/docs/development.zh.md b/docs/development.zh.md index be63a28fed..902b0e47b1 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -78,7 +78,7 @@ Host 与 Client 保持两个 aggregate program,是因为两侧在相同键下 拆分 Host/Client tsconfig 的包有六个:`api/remotes`、`api/gateway`、`api/session-controller`、`api/workspace-controller`、`client/connection` 与 `session-query/session-log-export`。`api/remotes` 的 Host 入口进入 Host Typert 图,而 Client 入口导入生成的 `/remote` 声明;`session-log-export` 则让 Node archive 生产代码不进入浏览器 controller。每个拆分包根 `tsconfig.json` 因此只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf;该门禁按「两个 leaf 配置同时存在」自动发现拆分包,所以新拆分的包会自动纳入管辖。[`api-remotes` README](../packages/api/remotes/README.zh.md) 与 [`session-log-export` README](../packages/session-query/session-log-export/README.zh.md)分别说明其拆分。 -`pnpm run build:artifacts` 按生成依赖顺序产出全部产物,只检查并编译各 compiler face 的包 reference: +`pnpm run build:artifacts` 产出全部产物,只检查并编译各 compiler face 的包 reference。Client 编译在 Host 编译完成后启动,与 Host 和 Desktop 打包并行;Client 打包等待 Client 编译和 Desktop 打包均完成。等价的串行顺序为: ```sh pnpm run build:native-system diff --git a/scripts/build.spec.ts b/scripts/build.spec.ts index 157edf8b46..31ee56df1d 100644 --- a/scripts/build.spec.ts +++ b/scripts/build.spec.ts @@ -11,8 +11,8 @@ const artifactGraph = { 'host-src': ['native', 'host-typert'], 'host-bundle': ['host-src'], 'desktop-bundle': ['host-bundle'], - 'client-src': ['desktop-bundle'], - 'client-bundle': ['client-src'], + 'client-src': ['host-src'], + 'client-bundle': ['desktop-bundle', 'client-src'], 'web': ['client-bundle'], } @@ -60,7 +60,7 @@ describe('repository build CLI', () => { }) describe('repository build steps', () => { - it('keeps artifact steps in serial dependency order', () => { + it('overlaps Client compilation with Host bundling while ordering bundle writers', () => { expect(graph(buildGates(root, 'artifacts'))).toEqual(artifactGraph) }) diff --git a/scripts/compile-referenced-projects.ts b/scripts/compile-referenced-projects.ts index fe0751b2d3..03b9a982be 100644 --- a/scripts/compile-referenced-projects.ts +++ b/scripts/compile-referenced-projects.ts @@ -166,10 +166,12 @@ function pnpmStep(id: LibraryStepId, label: string, args: readonly string[], nee /** * List the package-library build steps in Host, Desktop, then Client dependency order. * Source-only Typert generation supplies declarations before any package compilation. + * Client compilation consumes Host declarations and overlaps Host and Desktop bundling; + * Client bundling waits for both branches so bundle writers remain ordered. * @param root - Repository root containing the compiler and bundle configurations. * @param faces - `host` lists Typert generation, Host compilation, and bundling; `all` adds Desktop and Client. * @param mode - Check package types by default; benchmark emission defers diagnostics to required full builds. - * @returns The ordered steps; each step needs its predecessor. + * @returns Steps with explicit compiler and bundle dependencies. * @throws If an aggregate configuration is invalid. */ export function libraryBuildSteps(root: string, faces: 'host' | 'all', mode: CompilationMode = 'checked'): LibraryStep[] { @@ -182,8 +184,8 @@ export function libraryBuildSteps(root: string, faces: 'host' | 'all', mode: Com return [ ...host, pnpmStep('desktop-bundle', 'Desktop bundle', ['--filter', '@deepseek-ai/dsh-desktop', 'run', 'bundle'], ['host-bundle']), - compilerStep(root, 'client', mode, ['desktop-bundle']), - pnpmStep('client-bundle', 'Client bundle', ['exec', 'tsdown', '--config-loader', 'native', '--env.DSH_BUILD_FACE', 'client'], ['client-src']), + compilerStep(root, 'client', mode, ['host-src']), + pnpmStep('client-bundle', 'Client bundle', ['exec', 'tsdown', '--config-loader', 'native', '--env.DSH_BUILD_FACE', 'client'], ['desktop-bundle', 'client-src']), ] } From ec17e0127e81945b435f4f9f5ea5aad541179119 Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:55:55 +0800 Subject: [PATCH 08/46] perf(windows): seal runtime inventories with bounded reads --- apps/desktop/packaging/README.i18n.yaml | 4 +-- apps/desktop/packaging/README.md | 2 ++ apps/desktop/packaging/README.zh.md | 2 ++ apps/desktop/scripts/prepare-dsh.ts | 7 ++-- apps/desktop/scripts/sign-primary-runtime.ts | 4 +-- apps/desktop/src/runtime-tree.ts | 37 ++++++++++++++++---- apps/desktop/tests/runtime-tree.spec.ts | 25 ++++++++++++- 7 files changed, 68 insertions(+), 13 deletions(-) diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index 80ea8ba1b5..7542fbf49d 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -48,8 +48,8 @@ en: 34273efbc5084038 zh: f255e29623b6af76 /desktop-packaging/desktop-runtime-preparation: - en: 217f4585f65fc5b5 - zh: 30f5b1a894f9ec00 + en: bce2ee195a876985 + zh: ae45df21c23e86c2 /desktop-packaging/installation-and-release-verification: en: eb35eb973b796643 zh: c1c52311632b7296 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index 5736ef5ce3..ffed19d45c 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -272,6 +272,8 @@ Desktop dependency overrides are declared separately in the patch-policy module, Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares the target Electron distribution plus primary-runtime with its shared pnpm CLI. `prepare:dsh` installs the production graph once at build time, prepares materialized packages for electron-builder to archive under `app.asar/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. Signed macOS builds sign and verify native files before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the prepared runtime inventory is checked after native signing. Native executables and libraries are unpacked beside ASAR; Python, standalone Node and pnpm remain in external runtime resources. Windows packaging checks every prepared PE against its ASAR unpacked entry and byte-identical disk copy, including unsigned builds. Builder glob rules match braces in PE filenames as single-character wildcards, so a matching neighboring file may also be unpacked. Prepared runtime smoke uses the verified target descriptor rather than the build host architecture. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment. +Windows runtime sealing reads at most eight files concurrently, before and after native signing. A read failure drains outstanding reads without replacing the descriptor; successful sealing still requires the subsequent complete integrity and smoke checks. + Signed macOS packaging writes `Contents/Resources/app-update.yml` while assembling the App and before code signing, including the directory build that feeds the parallel ZIP and DMG lanes. The signing hook verifies the exact feed and updater cache directory. Both lane copies and the promoted App are checked again before the release completion record is written; a missing or mismatched configuration prevents artifact promotion and therefore prevents upload. An unpacked artifact contains Electron, the materialized dsh production tree, pnpm, and the shell application. Installer size and filesystem size differ; release qualification measures both, plus the profile’s plugin storage and first-launch latency. The runtime trades more application files for eliminating core package installation on the user’s machine. diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index 7e3febef7e..d3b5e2c241 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -272,6 +272,8 @@ Desktop 依赖 overrides 在补丁策略模块中单独声明,不从补丁版 每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Electron 分发包和包含共享 pnpm CLI 的 primary-runtime。`prepare:dsh` 在构建时安装一次生产依赖图,准备物化包供 electron-builder 归档到 `app.asar/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。macOS 签名构建先签名并验证原生文件,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;准备完成的运行时清单在原生签名后检查。原生可执行文件及库解包到 ASAR 旁;Python、独立 Node 和 pnpm 保留在外部 runtime 资源中。Windows 打包逐项检查准备好的 PE,确认其 ASAR 条目已标记为解包,且磁盘副本字节一致;未签名构建也执行此检查。Builder glob 规则用单字符通配符匹配 PE 文件名中的花括号,因此同目录中名称匹配的文件也可能被解包。准备好的运行时 smoke 沿用已验证的目标描述符,不使用构建宿主的架构。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。 +Windows 运行时在原生签名前后封存文件清单,每次最多并发读取八个文件。读取失败时会等待未完成的读取结束,不替换描述符;封存成功后仍须完成后续的完整性与 smoke 检查。 + macOS 签名打包在组装 App 时、代码签名前写入 `Contents/Resources/app-update.yml`,供并行 ZIP 与 DMG 路线使用的目录构建也执行此操作。签名钩子验证准确的更新源和 updater 缓存目录。写入发布完成记录前,流程会再次检查两条路线的副本和最终移入的 App;配置缺失或不匹配会阻止移入产物,因而也会阻止上传。 未压缩产物包含 Electron、物化后的 dsh 生产依赖树、pnpm,以及壳应用。安装包大小与文件系统占用不同;发布验收需要测量两者,以及 profile 插件存储和首次启动耗时。此布局用更多应用内文件换取消除用户机器上的核心包安装过程。 diff --git a/apps/desktop/scripts/prepare-dsh.ts b/apps/desktop/scripts/prepare-dsh.ts index 5c026e81bf..a069888036 100644 --- a/apps/desktop/scripts/prepare-dsh.ts +++ b/apps/desktop/scripts/prepare-dsh.ts @@ -23,7 +23,7 @@ import { smokePrimaryRuntime } from './prepare-primary-runtime.ts' import { smokePreparedRuntime } from './smoke-prepared-runtime.ts' import { prepareRuntimeManifests } from './prepare-runtime-manifests.ts' import { prepareRuntimePatches, verifyRuntimePatches } from './prepare-runtime-patches.ts' -import { writeDesktopRuntime, verifyDesktopRuntime } from '../src/runtime-tree.ts' +import { writeDesktopRuntime, writeDesktopRuntimeAsync, verifyDesktopRuntime } from '../src/runtime-tree.ts' import { resolveDesktopAppId, resolveMacOSSigningEnvironment, @@ -170,7 +170,10 @@ async function main(): Promise { } 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)) + await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:write-descriptor', async () => { + const writeRuntime = target.platform === 'win32' ? writeDesktopRuntimeAsync : writeDesktopRuntime + return writeRuntime(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 packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:smoke', () => smokePreparedRuntime(DSH_OUTPUT_ROOT, NODE, RUNTIME_ROOT, descriptor)) diff --git a/apps/desktop/scripts/sign-primary-runtime.ts b/apps/desktop/scripts/sign-primary-runtime.ts index d9a9b36c25..a15e378625 100644 --- a/apps/desktop/scripts/sign-primary-runtime.ts +++ b/apps/desktop/scripts/sign-primary-runtime.ts @@ -11,7 +11,7 @@ import { withWindowsSigningStage } from './windows-signing-stage.mjs' import { failPackagingRun, recordPackagingEvent } from './packaging-run.mjs' import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' import { smokePrimaryRuntime } from './prepare-primary-runtime.ts' -import { verifyDesktopRuntime, writeDesktopRuntime, type DesktopRuntimeDescriptor } from '../src/runtime-tree.ts' +import { verifyDesktopRuntime, writeDesktopRuntimeAsync, type DesktopRuntimeDescriptor } from '../src/runtime-tree.ts' import { smokePreparedRuntime } from './smoke-prepared-runtime.ts' /** @@ -39,7 +39,7 @@ export async function signWindowsDesktopRuntime(root: string, version: string, options: WindowsCodeSigningOptions & { smoke: (descriptor: DesktopRuntimeDescriptor) => Promise }): Promise { const descriptor = await verifyDesktopRuntime(root, version) await signWindowsCode(root, options) - writeDesktopRuntime(root, descriptor.release, descriptor.sharedPackages.map(entry => entry.name)) + await writeDesktopRuntimeAsync(root, descriptor.release, descriptor.sharedPackages.map(entry => entry.name)) const signed = await verifyDesktopRuntime(root, version) await options.smoke(signed) await verifyDesktopRuntime(root, version) diff --git a/apps/desktop/src/runtime-tree.ts b/apps/desktop/src/runtime-tree.ts index dd7941d8e8..5bc8b758cf 100644 --- a/apps/desktop/src/runtime-tree.ts +++ b/apps/desktop/src/runtime-tree.ts @@ -89,7 +89,7 @@ export function inventoryDesktopRuntime(root: string): DesktopRuntimeFile[] { .sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0) } -async function inventoryRuntimeForVerification(root: string): Promise { +async function inventoryRuntimeConcurrently(root: string): Promise { const entries = runtimeFiles(root) const files: DesktopRuntimeFile[] = [] const remaining = entries.values() @@ -125,6 +125,31 @@ export function writeDesktopRuntime( root: string, release: DesktopRelease, sharedNames: readonly string[], target: { platform: NodeJS.Platform; arch: string } = process, ): DesktopRuntimeDescriptor { + return writeRuntimeDescriptor(root, { + ...runtimeMetadata(root, release, sharedNames, target), files: inventoryDesktopRuntime(root), + }) +} + +/** + * Seal a materialized runtime with bounded concurrent reads, draining readers before failure returns. + * @param root - Runtime output directory. + * @param release - Matching shell, dsh, Host, and executable versions. + * @param sharedNames - Release-owned packages supplied to plugins. + * @param target - Platform and architecture selected by runtime preparation. + * @returns Descriptor written after every final file has been inventoried. + */ +export async function writeDesktopRuntimeAsync( + root: string, release: DesktopRelease, sharedNames: readonly string[], + target: { platform: NodeJS.Platform; arch: string } = process, +): Promise { + const metadata = runtimeMetadata(root, release, sharedNames, target) + return writeRuntimeDescriptor(root, { ...metadata, files: await inventoryRuntimeConcurrently(root) }) +} + +function runtimeMetadata( + root: string, release: DesktopRelease, sharedNames: readonly string[], + target: { platform: NodeJS.Platform; arch: string }, +): Omit { const sharedPackages = [...new Set(sharedNames)].sort().map((name) => { if (!PACKAGE_NAME.test(name)) throw new Error(`desktop runtime: invalid shared package ${name}`) const path = `node_modules/${name}` @@ -134,10 +159,10 @@ export function writeDesktopRuntime( } return { name, version: manifest.version, path } }) - const descriptor: DesktopRuntimeDescriptor = { - schemaVersion: 1, release, platform: target.platform, arch: target.arch, - sharedPackages, files: inventoryDesktopRuntime(root), - } + return { schemaVersion: 1, release, platform: target.platform, arch: target.arch, sharedPackages } +} + +function writeRuntimeDescriptor(root: string, descriptor: DesktopRuntimeDescriptor): DesktopRuntimeDescriptor { writeFileSync(join(root, DESKTOP_RUNTIME_FILE), `${JSON.stringify(descriptor, undefined, 2)}\n`) return descriptor } @@ -203,7 +228,7 @@ export async function verifyDesktopRuntime( throw new Error(`desktop runtime: shared package metadata mismatch for ${entry.name}`) } } - const actual = await inventoryRuntimeForVerification(root) + const actual = await inventoryRuntimeConcurrently(root) // Windows has no portable Unix executable permission bits. const comparable = (items: readonly DesktopRuntimeFile[]): unknown => process.platform === 'win32' ? items.map(({ executable: _executable, ...item }) => item) : items diff --git a/apps/desktop/tests/runtime-tree.spec.ts b/apps/desktop/tests/runtime-tree.spec.ts index dadc4ca08d..1c8c2c58e1 100644 --- a/apps/desktop/tests/runtime-tree.spec.ts +++ b/apps/desktop/tests/runtime-tree.spec.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, expect, it } from 'vitest' import { DESKTOP_HOST_PACKAGE, DESKTOP_HOST_RUNTIME_FILES } from '../src/core-package-set.ts' -import { DESKTOP_RUNTIME_FILE, readDesktopRuntime, runtimePath, verifyDesktopRuntime } from '../src/runtime-tree.ts' +import { DESKTOP_RUNTIME_FILE, readDesktopRuntime, runtimePath, verifyDesktopRuntime, writeDesktopRuntime, writeDesktopRuntimeAsync } from '../src/runtime-tree.ts' import { runtimeFixture } from './runtime-fixture.ts' const roots: string[] = [] @@ -15,6 +15,29 @@ function fixture(): string { } afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) +it('seals final file bytes identically with concurrent and synchronous inventory', async () => { + const dsh = join(fixture(), 'dsh') + const original = readDesktopRuntime(dsh) + const names = original.sharedPackages.map(entry => entry.name) + writeFileSync(join(dsh, 'extra.bin'), Buffer.from([0, 1, 255])) + writeFileSync(join(dsh, 'package.json'), '{"type":"module","private":true}\n') + const expected = writeDesktopRuntime(dsh, original.release, names) + const expectedBytes = readFileSync(join(dsh, DESKTOP_RUNTIME_FILE)) + expect(await writeDesktopRuntimeAsync(dsh, original.release, names)).toEqual(expected) + expect(readFileSync(join(dsh, DESKTOP_RUNTIME_FILE))).toEqual(expectedBytes) + expect(await verifyDesktopRuntime(dsh, original.release.version)).toEqual(expected) +}) + +it.each(['manifest', 'link'])('leaves the descriptor unchanged when concurrent sealing rejects a %s', async (failure) => { + const dsh = join(fixture(), 'dsh') + const original = readDesktopRuntime(dsh) + const before = readFileSync(join(dsh, DESKTOP_RUNTIME_FILE)) + if (failure === 'manifest') writeFileSync(join(dsh, 'node_modules', DESKTOP_HOST_PACKAGE, 'package.json'), '{}') + else symlinkSync(join(dsh, 'node_modules'), join(dsh, 'outside'), process.platform === 'win32' ? 'junction' : 'dir') + await expect(writeDesktopRuntimeAsync(dsh, original.release, original.sharedPackages.map(entry => entry.name))).rejects.toThrow() + expect(readFileSync(join(dsh, DESKTOP_RUNTIME_FILE))).toEqual(before) +}) + it('verifies a runtime after relocation without depending on build paths', async () => { const root = fixture() const before = await verifyDesktopRuntime(join(root, 'dsh'), '1.0.0') From d148052e8336d5b754e0fefdae541a0642125709 Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:30:35 +0800 Subject: [PATCH 09/46] perf(windows): batch restored runtime signature verification --- .../scripts/windows-runtime-signature.d.mts | 2 +- .../scripts/windows-runtime-signature.mjs | 19 +++-- .../tests/primary-runtime-signing.spec.ts | 83 +++++++++++++++---- apps/desktop/windows-signing/README.i18n.yaml | 4 +- apps/desktop/windows-signing/README.md | 2 +- apps/desktop/windows-signing/README.zh.md | 2 +- 6 files changed, 83 insertions(+), 29 deletions(-) diff --git a/apps/desktop/scripts/windows-runtime-signature.d.mts b/apps/desktop/scripts/windows-runtime-signature.d.mts index f47e306885..914cfaf84c 100644 --- a/apps/desktop/scripts/windows-runtime-signature.d.mts +++ b/apps/desktop/scripts/windows-runtime-signature.d.mts @@ -49,7 +49,7 @@ export function windowsRuntimeCode(root: string): Promise * Preserve valid signatures and sign unsigned PE files with a supervised signer. * @param root Materialized directory to sign. * @param options Signer, certificate identity and audit sink. - * @returns Resolves after verified parallel cache restores and sequential signing; stops dispatch and drains active restores on failure, without retries. + * @returns Resolves after parallel cache restores, batched target verification and sequential signing; failures drain active restores or inspections without retries. */ export function signWindowsCode(root: string, options: WindowsCodeSigningOptions): Promise diff --git a/apps/desktop/scripts/windows-runtime-signature.mjs b/apps/desktop/scripts/windows-runtime-signature.mjs index 8780f182ca..592ea9f9e5 100644 --- a/apps/desktop/scripts/windows-runtime-signature.mjs +++ b/apps/desktop/scripts/windows-runtime-signature.mjs @@ -101,7 +101,7 @@ async function inspectWindowsCode(files, inspect) { * Preserve valid signatures and sign unsigned PE files with a supervised signer. * @param {string} root - Owned final runtime directory. * @param {import('./windows-runtime-signature.mjs').WindowsCodeSigningOptions} options - Supervised signer, certificate identity, audit sink. - * @returns {Promise} Resolves after bounded cache restores and sequential hardware signing, each with verification; failures drain active restores before rejecting. + * @returns {Promise} Resolves after bounded cache restores, batched target verification and sequential hardware signing; failures drain active work before rejecting. */ export async function signWindowsCode(root, options) { const inspect = options.inspect ?? inspectWindowsRuntimeSignature @@ -116,12 +116,15 @@ export async function signWindowsCode(root, options) { else if (signature.status !== 'Valid') throw new Error(`Windows code: refusing ${signature.status} signature: ${path}`) } options.record({ type: 'windows-code-signing-plan', files: files.length, unsigned: unsigned.length }) - async function verifySigned(path) { - const signature = await inspect(path) - if (signature.status !== 'Valid' || !signature.timestamped || signature.thumbprint?.toUpperCase() !== options.thumbprint.toUpperCase()) { - throw new Error(`Windows code: signing verification failed: ${path}`) + async function verifySigned(paths) { + const verified = await inspectWindowsCode(paths, inspect) + for (const [index, path] of paths.entries()) { + const signature = verified[index] + if (signature.status !== 'Valid' || !signature.timestamped || signature.thumbprint?.toUpperCase() !== options.thumbprint.toUpperCase()) { + throw new Error(`Windows code: signing verification failed: ${path}`) + } + options.record({ type: 'windows-code-signature-verified', path, ...signature }) } - options.record({ type: 'windows-code-signature-verified', path, ...signature }) } const restored = new Set() if (options.cache) { @@ -132,7 +135,6 @@ export async function signWindowsCode(root, options) { const path = unsigned[next++] try { if (await options.cache.restore({ path, hash: 'sha256', isNest: false })) { - await verifySigned(path) restored.add(path) } } catch (error) { failure ??= { error } } @@ -140,11 +142,12 @@ export async function signWindowsCode(root, options) { } await Promise.all(Array.from({ length: Math.min(options.cache.concurrency, unsigned.length) }, worker)) if (failure !== undefined) throw failure.error + await verifySigned(unsigned.filter(path => restored.has(path))) } for (const path of unsigned) { if (restored.has(path)) continue await options.sign({ path, hash: 'sha256', isNest: false }) - await verifySigned(path) + await verifySigned([path]) } } diff --git a/apps/desktop/tests/primary-runtime-signing.spec.ts b/apps/desktop/tests/primary-runtime-signing.spec.ts index 5c7b4aa080..2aae8516c5 100644 --- a/apps/desktop/tests/primary-runtime-signing.spec.ts +++ b/apps/desktop/tests/primary-runtime-signing.spec.ts @@ -65,32 +65,84 @@ it('bounds overlapping cache restores and verifies all hits before serial hardwa expect(sign.mock.calls.map(([request]) => request.path)).toEqual(['c.exe', 'd.exe'].map(name => join(root, name))) }) -it.each(['restore', 'verification', 'audit'])('drains active cache restores after %s failure without dispatching more work', async (failure) => { +it('drains active cache restores after failure without dispatching more work', async () => { const root = await fixture(['a.exe', 'b.exe', 'c.exe']) const entered = Promise.withResolvers() const release = Promise.withResolvers() const failed = Promise.withResolvers() - const restored = new Set() const calls: string[] = [] const sign = vi.fn(async () => {}) let settled = false const operation = signWindowsCode(root, { thumbprint, sign, - record: (event) => { - if (failure === 'audit' && 'type' in event && event.type === 'windows-code-signature-verified') { - failed.resolve(undefined) - throw new Error('audit failed') - } - }, - inspect: async (path) => { - if (!restored.has(path)) return unsigned - if (failure === 'verification' && path.endsWith('a.exe')) { failed.resolve(undefined); throw new Error('verification failed') } - return valid - }, + record: () => {}, + inspect: async () => unsigned, cache: { concurrency: 2, restore: async ({ path }) => { calls.push(path) if (path.endsWith('b.exe')) { entered.resolve(undefined); await release.promise; return false } await entered.promise - if (failure === 'restore') { failed.resolve(undefined); throw new Error('restore failed') } + failed.resolve(undefined) + throw new Error('restore failed') + } }, + }).then(() => { settled = true; return undefined }, (error: unknown) => { settled = true; return error }) + try { + await Promise.race([failed.promise, operation]) + await new Promise(resolve => setImmediate(resolve)) + expect(settled).toBe(false) + expect(sign).not.toHaveBeenCalled() + } finally { release.resolve(undefined); await operation } + expect(await operation).toEqual(new Error('restore failed')) + expect(calls).toEqual(['a.exe', 'b.exe'].map(name => join(root, name))) + expect(sign).not.toHaveBeenCalled() +}) + +it.each(['trust', 'timestamp', 'certificate', 'audit'])('rejects restored target %s failures before hardware signing', async (failure) => { + const root = await fixture(['a.exe', 'b.exe', 'miss.exe']) + const restored = new Set() + let restores = 0 + const sign = vi.fn(async () => {}) + await expect(signWindowsCode(root, { thumbprint, sign, + record: (event) => { + if (failure === 'audit' && 'type' in event && event.type === 'windows-code-signature-verified') throw new Error('audit failed') + }, + inspect: async (path) => { + if (!restored.has(path)) return unsigned + expect(restores).toBe(3) + return { + ...valid, + ...(failure === 'trust' ? { status: 'HashMismatch' } : {}), + ...(failure === 'timestamp' ? { timestamped: false } : {}), + ...(failure === 'certificate' ? { thumbprint: 'B'.repeat(40) } : {}), + } + }, + cache: { concurrency: 2, restore: async ({ path }) => { + restores++ + if (path.endsWith('miss.exe')) return false + restored.add(path) + return true + } }, + })).rejects.toThrow(failure === 'audit' ? 'audit failed' : 'signing verification failed') + expect(restores).toBe(3) + expect(sign).not.toHaveBeenCalled() +}) + +it('drains failed restored-target inspection before returning and never signs misses', async () => { + const root = await fixture(['a.exe', 'b.exe', 'miss.exe']) + const entered = Promise.withResolvers() + const failed = Promise.withResolvers() + const release = Promise.withResolvers() + const restored = new Set() + const sign = vi.fn(async () => {}) + let settled = false + const operation = signWindowsCode(root, { thumbprint, sign, record: () => {}, + inspect: async (path) => { + if (!restored.has(path)) return unsigned + if (path.endsWith('b.exe')) { entered.resolve(undefined); await release.promise; return valid } + await entered.promise + failed.resolve(undefined) + throw new Error('verification failed') + }, + cache: { concurrency: 2, restore: async ({ path }) => { + if (path.endsWith('miss.exe')) return false restored.add(path) return true } }, @@ -101,8 +153,7 @@ it.each(['restore', 'verification', 'audit'])('drains active cache restores afte expect(settled).toBe(false) expect(sign).not.toHaveBeenCalled() } finally { release.resolve(undefined); await operation } - expect(await operation).toEqual(new Error(`${failure} failed`)) - expect(calls).toEqual(['a.exe', 'b.exe'].map(name => join(root, name))) + expect(await operation).toEqual(new Error('verification failed')) expect(sign).not.toHaveBeenCalled() }) diff --git a/apps/desktop/windows-signing/README.i18n.yaml b/apps/desktop/windows-signing/README.i18n.yaml index f64f0ef778..4d0be04be3 100644 --- a/apps/desktop/windows-signing/README.i18n.yaml +++ b/apps/desktop/windows-signing/README.i18n.yaml @@ -33,8 +33,8 @@ en: 5401b605a2f3a44e zh: fc1f6ee667bcd20d /windows-signing-and-runner-operations/signature-cache-and-concurrent-stages: - en: c2686bf2e9589521 - zh: 58dfecf61e4f95da + en: 1ab28ad6c6489102 + zh: 0ee6df9aaaeddace /windows-signing-and-runner-operations/verification-limits: en: 94a471410c84a1c7 zh: 115228e4ca36581d diff --git a/apps/desktop/windows-signing/README.md b/apps/desktop/windows-signing/README.md index ec8312eddc..af409d4c39 100644 --- a/apps/desktop/windows-signing/README.md +++ b/apps/desktop/windows-signing/README.md @@ -88,7 +88,7 @@ Runtime signing shares complete signed files across the current Windows account' Preflight, primary-runtime signing, application-runtime signing and artifact creation each hold the account's signing-stage lock until their supervised child processes settle. Other builds wait before entering those stages; compilation and preparation do not hold the lock. Runtime signing workers own their locks, so terminating only the outer packaging process does not unlock a surviving cache user. Migration and maintenance acquire the same lock; older or external signing commands do not participate and must be scheduled separately. Stage waiting is outside the preflight deadline. Closing the stage handle releases ordinary contention without removing the separate hardware-attempt interlock; a retained hardware failure still requires operator recovery. -Cache-hit copying, hashing and per-file trust checks run with `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` workers from `.env.windows` (default `4`, integer `1`–`8`). All restores and their post-verification finish before cache misses enter serial hardware signing. A restore or verification failure stops new dispatch and drains active work before releasing the stage lock; hardware signing, runtime smoke checks and final integrity validation remain required. +Cache-hit copying, hashing and per-file candidate trust checks run with `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` workers from `.env.windows` (default `4`, integer `1`–`8`). After all restores finish, bounded PowerShell batches verify every restored target's trust, timestamp and certificate before cache misses enter serial hardware signing. A restore failure stops new restores and drains active workers; a target-verification failure drains its inspection batch and prevents hardware signing. Runtime smoke checks and final integrity validation remain required. Windows packaging reads `DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY` from `.env.windows` or the selected preset: a positive integer, defaulting to `4`. Packaging batches first-party and vendor tarballs with bounded workers, then validates every payload and records publication order. Setting the limit to `1` selects serial packing; standalone `release:pack` defaults to `1`. diff --git a/apps/desktop/windows-signing/README.zh.md b/apps/desktop/windows-signing/README.zh.md index 3b957c5f57..ef76332edb 100644 --- a/apps/desktop/windows-signing/README.zh.md +++ b/apps/desktop/windows-signing/README.zh.md @@ -88,7 +88,7 @@ Windows 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `.desktop-build/pac 预检、主要运行时签名、应用运行时签名和产物生成分别持有账户级签名阶段锁,直到受监督的子进程结束。其他构建进入这些阶段前等待;编译和准备步骤不持锁。运行时签名进程自行持锁,因此仅终止外层打包进程不会释放仍在访问缓存的阶段锁。迁移及维护获取同一把锁;旧版或外部签名命令不参与排队,应另行避免并发。阶段等待不计入预检期限。关闭阶段句柄会释放普通竞争锁,不删除独立的硬件尝试互锁;遗留硬件失败仍需操作人员恢复。 -缓存命中的复制、摘要和逐文件信任检查使用 `.env.windows` 中的 `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` 个工作任务(默认 `4`,整数 `1`–`8`)。所有恢复及后置验签完成后,未命中项才进入串行硬件签名。恢复或验签失败会停止派发新任务,并在释放阶段锁前等待在途工作结束;硬件签名、运行时 smoke 检查和最终完整性校验仍须执行。 +缓存命中的复制、摘要和逐文件候选信任检查使用 `.env.windows` 中的 `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` 个工作任务(默认 `4`,整数 `1`–`8`)。所有恢复完成后,有界 PowerShell 批次逐项验证恢复目标的信任、时间戳和证书,通过后未命中项才进入串行硬件签名。恢复失败会停止派发新的恢复任务并等待在途工作结束;目标验签失败会等待当前检查批次结束并阻止硬件签名。运行时 smoke 检查和最终完整性校验仍须执行。 Windows 打包从 `.env.windows` 或所选预设读取 `DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY`:必须为正整数,默认 `4`。Windows 使用有界 worker 批量打包第一方和 vendor tarball,随后校验每个产物并记录发布顺序。设为 `1` 时串行打包;独立的 `release:pack` 默认使用 `1`。 From e7a9e2683d6aeece0f586caaa2e3114a75cfb362 Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:39:40 +0800 Subject: [PATCH 10/46] perf(windows): bound parallel PE header discovery --- .../scripts/windows-runtime-signature.d.mts | 2 +- .../scripts/windows-runtime-signature.mjs | 47 +++++-- .../tests/windows-runtime-code.spec.ts | 128 ++++++++++++++++++ apps/desktop/windows-signing/README.i18n.yaml | 4 +- apps/desktop/windows-signing/README.md | 2 + apps/desktop/windows-signing/README.zh.md | 2 + 6 files changed, 174 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/tests/windows-runtime-code.spec.ts diff --git a/apps/desktop/scripts/windows-runtime-signature.d.mts b/apps/desktop/scripts/windows-runtime-signature.d.mts index 914cfaf84c..b5624c2577 100644 --- a/apps/desktop/scripts/windows-runtime-signature.d.mts +++ b/apps/desktop/scripts/windows-runtime-signature.d.mts @@ -41,7 +41,7 @@ export interface WindowsCodeSigningOptions { /** * Enumerate PE files by content without following links. * @param root Materialized directory to scan. - * @returns Sorted PE paths; rejects malformed Windows executables and links. + * @returns Sorted PE paths; eight bounded readers reject links or malformed executables and drain open handles before failure. */ export function windowsRuntimeCode(root: string): Promise diff --git a/apps/desktop/scripts/windows-runtime-signature.mjs b/apps/desktop/scripts/windows-runtime-signature.mjs index 592ea9f9e5..9f79547b34 100644 --- a/apps/desktop/scripts/windows-runtime-signature.mjs +++ b/apps/desktop/scripts/windows-runtime-signature.mjs @@ -6,6 +6,7 @@ import { promisify } from 'node:util' import { scrubWindowsSigningEnvironment } from './windows-sign.mjs' import { recordPackagingEvent } from './packaging-run.mjs' import { inspectSignaturesBatched } from './windows-signature-batch.mjs' +import { packagingStep } from './packaging-step.mjs' /** * Read Windows trust, timestamp and signer identity using the engine's bundled modules, without accessing the private key. @@ -52,17 +53,32 @@ export async function preserveWindowsRuntimeSignature(path, options) { /** * Enumerate Windows code without following links or treating foreign .node files as PE binaries. * @param {string} root - Owned, materialized runtime directory. - * @returns {Promise} Sorted real PE files; rejects links and malformed Windows executable files. + * @returns {Promise} Sorted real PE files; eight bounded readers reject links or malformed executables and drain open handles before failure. */ export async function windowsRuntimeCode(root) { - const rootStat = await lstat(root) - if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error('Windows code: expected a real directory') + return packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'windows:enumerate-code', async () => { + const candidates = [] + async function collect(directory) { + const rootStat = await lstat(directory) + if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error('Windows code: expected a real directory') + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name) + if (entry.isSymbolicLink()) throw new Error(`Windows code: directory links are not signable: ${path}`) + if (entry.isDirectory()) await collect(path) + else if (entry.isFile()) candidates.push(path) + } + } + await collect(root) + return inspectPortableExecutables(candidates) + }) +} + +/** Close every active file before returning a sorted inventory or the first inspection failure. */ +async function inspectPortableExecutables(candidates) { const files = [] - for (const entry of await readdir(root, { withFileTypes: true })) { - const path = join(root, entry.name) - if (entry.isSymbolicLink()) throw new Error(`Windows code: directory links are not signable: ${path}`) - if (entry.isDirectory()) { files.push(...await windowsRuntimeCode(path)); continue } - if (!entry.isFile()) continue + let next = 0 + let failure + async function inspect(path) { const file = await open(path, 'r') let portableExecutable = false let windowsCandidate = false @@ -80,11 +96,26 @@ export async function windowsRuntimeCode(root) { if (portableExecutable) files.push(path) else if (windowsCandidate || ['.exe', '.dll', '.pyd'].includes(extname(path).toLowerCase())) throw new Error(`Windows code: invalid PE file: ${path}`) } + async function worker() { + while (failure === undefined && next < candidates.length) { + const path = candidates[next++] + try { await inspect(path) } + catch (error) { failure ??= { error } } + } + } + await Promise.all(Array.from({ length: Math.min(8, candidates.length) }, worker)) + if (failure !== undefined) throw failure.error return files.sort() } /** Drain each public-key verification batch before signing or reporting a failure. */ async function inspectWindowsCode(files, inspect) { + return packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'windows:inspect-signatures', + () => inspectWindowsCodeBatch(files, inspect)) +} + +/** Complete bounded inspections without allowing callers to proceed past an active failed batch. */ +async function inspectWindowsCodeBatch(files, inspect) { if (inspect === inspectWindowsRuntimeSignature) return inspectSignaturesBatched(files) const signatures = [] for (let offset = 0; offset < files.length; offset += 4) { diff --git a/apps/desktop/tests/windows-runtime-code.spec.ts b/apps/desktop/tests/windows-runtime-code.spec.ts new file mode 100644 index 0000000000..d3f1530aaf --- /dev/null +++ b/apps/desktop/tests/windows-runtime-code.spec.ts @@ -0,0 +1,128 @@ +/** PE discovery keeps content checks and drains bounded file reads before failure. */ +import { mkdir, mkdtemp, rm, writeFile, type FileHandle } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { setImmediate } from 'node:timers/promises' +import { afterEach, expect, it, vi } from 'vitest' +import { windowsRuntimeCode } from '../scripts/windows-runtime-signature.mjs' + +const hooks = vi.hoisted(() => ({ + opened: undefined as ((handle: FileHandle) => void) | undefined, +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + open: async (...args: Parameters) => { + const handle = await actual.open(...args) + hooks.opened?.(handle) + return handle + }, + } +}) + +const roots: string[] = [] + +async function fixture(entries: Record): Promise { + const root = await mkdtemp(join(tmpdir(), 'windows-runtime-code-')) + roots.push(root) + for (const [name, contents] of Object.entries(entries)) { + const path = join(root, name) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, contents) + } + return root +} + +function executable(): Buffer { + const bytes = Buffer.alloc(128) + bytes.writeUInt16LE(0x5a4d, 0) + bytes.writeUInt32LE(64, 0x3c) + bytes.writeUInt32LE(0x4550, 64) + return bytes +} + +afterEach(async () => { + hooks.opened = undefined + vi.restoreAllMocks() + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) +}) + +it('discovers extensionless PE files in nested directories and excludes foreign native modules', async () => { + const root = await fixture({ + 'nested/deeper/extensionless': executable(), + 'nested/runtime.node': executable(), + 'a.exe': executable(), + 'nested/deeper/foreign.node': Buffer.from([0x7f, 0x45, 0x4c, 0x46]), + 'readme.txt': 'ordinary data', + }) + expect(await windowsRuntimeCode(root)).toEqual([ + 'nested/deeper/extensionless', 'nested/runtime.node', 'a.exe', + ].map(name => join(root, name)).sort()) +}) + +it.each([ + ['broken.exe', Buffer.from('not PE')], + ['nested/extensionless', Buffer.from('MZ')], + ['nested/broken.node', Buffer.from('MZ')], + ['nested/deeper/broken.dll', Buffer.alloc(64)], +])('rejects malformed Windows code at %s', async (name, contents) => { + const root = await fixture({ [name]: contents }) + await expect(windowsRuntimeCode(root)).rejects.toThrow(`Windows code: invalid PE file: ${join(root, name)}`) +}) + +it('limits open files to eight and drains pending closes before rejecting a read failure', async () => { + const root = await fixture(Object.fromEntries(Array.from({ length: 24 }, (_, index) => [ + `${String(index).padStart(2, '0')}.txt`, 'ordinary data', + ]))) + const releaseReads = Promise.withResolvers() + const releaseCloses = Promise.withResolvers() + const failedClosed = Promise.withResolvers() + const failure = new Error('controlled PE header read failure') + let opened = 0 + let active = 0 + let peak = 0 + let settled = false + hooks.opened = (handle) => { + const index = opened++ + active++ + peak = Math.max(peak, active) + const close = handle.close.bind(handle) + vi.spyOn(handle, 'read').mockImplementationOnce(async () => { + await releaseReads.promise + if (index === 0) throw failure + return { bytesRead: 0, buffer: Buffer.alloc(64) } + }) + vi.spyOn(handle, 'close').mockImplementationOnce(async () => { + if (index !== 0) await releaseCloses.promise + await close() + active-- + if (index === 0) failedClosed.resolve(undefined) + }) + } + const operation = windowsRuntimeCode(root).then( + (result) => { settled = true; return result }, + (error: unknown) => { settled = true; return error }, + ) + try { + await vi.waitFor(() => { expect(opened).toBe(8) }) + expect(active).toBe(8) + releaseReads.resolve(undefined) + await failedClosed.promise + await setImmediate() + expect(settled).toBe(false) + expect(opened).toBe(8) + expect(active).toBe(7) + releaseCloses.resolve(undefined) + expect(await operation).toBe(failure) + expect(opened).toBe(8) + expect(peak).toBe(8) + expect(active).toBe(0) + } finally { + hooks.opened = undefined + releaseReads.resolve(undefined) + releaseCloses.resolve(undefined) + await operation + } +}) diff --git a/apps/desktop/windows-signing/README.i18n.yaml b/apps/desktop/windows-signing/README.i18n.yaml index 4d0be04be3..dda6a15aa1 100644 --- a/apps/desktop/windows-signing/README.i18n.yaml +++ b/apps/desktop/windows-signing/README.i18n.yaml @@ -33,8 +33,8 @@ en: 5401b605a2f3a44e zh: fc1f6ee667bcd20d /windows-signing-and-runner-operations/signature-cache-and-concurrent-stages: - en: 1ab28ad6c6489102 - zh: 0ee6df9aaaeddace + en: 78593443275fe259 + zh: 9a8c1b541f479273 /windows-signing-and-runner-operations/verification-limits: en: 94a471410c84a1c7 zh: 115228e4ca36581d diff --git a/apps/desktop/windows-signing/README.md b/apps/desktop/windows-signing/README.md index af409d4c39..2ef710e820 100644 --- a/apps/desktop/windows-signing/README.md +++ b/apps/desktop/windows-signing/README.md @@ -90,6 +90,8 @@ Preflight, primary-runtime signing, application-runtime signing and artifact cre Cache-hit copying, hashing and per-file candidate trust checks run with `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` workers from `.env.windows` (default `4`, integer `1`–`8`). After all restores finish, bounded PowerShell batches verify every restored target's trust, timestamp and certificate before cache misses enter serial hardware signing. A restore failure stops new restores and drains active workers; a target-verification failure drains its inspection batch and prevents hardware signing. Runtime smoke checks and final integrity validation remain required. +PE discovery reads every regular file's header with at most eight open files, rejects links and malformed Windows executables, and drains active reads and closes before reporting failure. Packaging records `windows:enumerate-code` and `windows:inspect-signatures` as nested phases of signing and artifact verification; their timings are included in the enclosing stages. + Windows packaging reads `DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY` from `.env.windows` or the selected preset: a positive integer, defaulting to `4`. Packaging batches first-party and vendor tarballs with bounded workers, then validates every payload and records publication order. Setting the limit to `1` selects serial packing; standalone `release:pack` defaults to `1`. Each runtime stage writes `SIGNATURE_CACHE_SUMMARY` to stdout and its packaging journal, including the actual directory, policy identity, hit/miss counts, newly published and retained entries, signing requests, avoided signing requests and validation failures. Timings distinguish signing, trust verification of restored and newly signed files, and restoration; restoration includes its trust check and staging cleanup. These counters exclude preflight, final artifact signing and the outer runtime signature checks. Stage lock events record waiting separately. Concurrent timings sum per-file work rather than stage wall time. diff --git a/apps/desktop/windows-signing/README.zh.md b/apps/desktop/windows-signing/README.zh.md index ef76332edb..7960543a17 100644 --- a/apps/desktop/windows-signing/README.zh.md +++ b/apps/desktop/windows-signing/README.zh.md @@ -90,6 +90,8 @@ Windows 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `.desktop-build/pac 缓存命中的复制、摘要和逐文件候选信任检查使用 `.env.windows` 中的 `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` 个工作任务(默认 `4`,整数 `1`–`8`)。所有恢复完成后,有界 PowerShell 批次逐项验证恢复目标的信任、时间戳和证书,通过后未命中项才进入串行硬件签名。恢复失败会停止派发新的恢复任务并等待在途工作结束;目标验签失败会等待当前检查批次结束并阻止硬件签名。运行时 smoke 检查和最终完整性校验仍须执行。 +PE 发现会读取每个普通文件的头部,同时最多打开八个文件,拒绝链接和损坏的 Windows 可执行文件,并在报告失败前等待在途读取和关闭结束。打包将 `windows:enumerate-code` 与 `windows:inspect-signatures` 记录为签名和产物验证的嵌套阶段;其耗时已包含在外层阶段中。 + Windows 打包从 `.env.windows` 或所选预设读取 `DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY`:必须为正整数,默认 `4`。Windows 使用有界 worker 批量打包第一方和 vendor tarball,随后校验每个产物并记录发布顺序。设为 `1` 时串行打包;独立的 `release:pack` 默认使用 `1`。 每个运行时阶段向标准输出及打包日志写入 `SIGNATURE_CACHE_SUMMARY`,包含实际目录、策略标识、命中及未命中数、新发布及保留项数、签名请求数、省去的签名请求数和验证失败数。计时区分签名、恢复文件和新签名文件的信任验证及恢复;恢复耗时包含其信任检查和暂存清理。这些统计不包含预检、最终产物签名及外层运行时验签。阶段锁事件单独记录等待时间。 并发计时累加逐文件工作耗时,不代表阶段墙钟时间。 From 8c56ebbb3bfc9d7b5321f5a846eb6ced610d2c0d Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:14:15 +0800 Subject: [PATCH 11/46] perf(windows): overlap package and runtime preparation --- apps/desktop/scripts/package-target.ts | 68 +++++++++------ .../tests/package-target-stages.spec.ts | 83 ++++++++++++++++++- apps/desktop/windows-signing/README.i18n.yaml | 4 +- apps/desktop/windows-signing/README.md | 2 +- apps/desktop/windows-signing/README.zh.md | 2 +- 5 files changed, 128 insertions(+), 31 deletions(-) diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index 401fafa771..7a56b52e1b 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -370,7 +370,7 @@ async function main(): Promise { version: buildVersion, productVersion, node: process.version, commit: packaged.commit, dirty: packaged.dirty, - }, { parallel: target.platform === 'darwin', secrets }) + }, { parallel: true, 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 @@ -479,31 +479,47 @@ export async function packageTarget( }) } await execute(invocation.unsigned ? ['run', 'build', '--profile', 'local'] : ['run', 'build:official'], buildEnv, REPOSITORY_ROOT) - await execute([ - ...(invocation.unsigned - ? ['exec', 'tsx', 'apps/desktop/scripts/pack-local-packages.ts'] - : ['run', 'release:pack', '--family', 'dsh']), - '--out', buildPaths.packedDsh, ...packArguments, - ], buildEnv, REPOSITORY_ROOT) - await execute([ - '--dir', - 'apps/desktop-host', - 'pack', - '--pack-destination', - buildPaths.packedDsh, - ], 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) - await execute([ - '--dir', - 'native/system/packages/entry', - 'pack', - '--pack-destination', - buildPaths.packedLandlock, - ], buildEnv, REPOSITORY_ROOT) - await execute(['run', 'prepare:runtime', ...(signPrimaryRuntime ? ['--defer-primary-runtime-smoke'] : [])], downloadEnv) + let preparationFailure: { error: unknown } | undefined + const executePreparation = async (args: readonly string[], env: NodeJS.ProcessEnv, cwd?: string): Promise => { + if (preparationFailure !== undefined) throw preparationFailure.error + try { await execute(args, env, cwd) } + catch (error) { preparationFailure ??= { error }; throw error } + } + const pack = async (): Promise => { + await executePreparation([ + ...(invocation.unsigned + ? ['exec', 'tsx', 'apps/desktop/scripts/pack-local-packages.ts'] + : ['run', 'release:pack', '--family', 'dsh']), + '--out', buildPaths.packedDsh, ...packArguments, + ], buildEnv, REPOSITORY_ROOT) + await executePreparation([ + '--dir', + 'apps/desktop-host', + 'pack', + '--pack-destination', + buildPaths.packedDsh, + ], buildEnv, REPOSITORY_ROOT) + await executePreparation(['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 executePreparation(['--dir', 'native/system', 'run', 'build:ts'], buildEnv, REPOSITORY_ROOT) + await executePreparation([ + '--dir', + 'native/system/packages/entry', + 'pack', + '--pack-destination', + buildPaths.packedLandlock, + ], buildEnv, REPOSITORY_ROOT) + } + const prepareRuntime = () => executePreparation( + ['run', 'prepare:runtime', ...(signPrimaryRuntime ? ['--defer-primary-runtime-smoke'] : [])], downloadEnv) + if (target.platform === 'win32') { + const results = await Promise.allSettled([pack(), prepareRuntime()]) + for (const result of results) if (result.status === 'rejected') throw result.reason + } else { + await pack() + await prepareRuntime() + } if (signPrimaryRuntime) await execute(['run', 'sign:primary-runtime'], electronBuilderEnv) await execute(['run', 'prepare:packages'], targetEnv) await execute(['run', 'prepare:dsh', ...(signPrimaryRuntime ? ['--defer-runtime-smoke'] : [])], downloadEnv) diff --git a/apps/desktop/tests/package-target-stages.spec.ts b/apps/desktop/tests/package-target-stages.spec.ts index c8e90de598..091de3c050 100644 --- a/apps/desktop/tests/package-target-stages.spec.ts +++ b/apps/desktop/tests/package-target-stages.spec.ts @@ -1,5 +1,6 @@ import { writeFileSync } from 'node:fs' import { resolve } from 'node:path' +import { setImmediate } from 'node:timers/promises' import { afterEach, expect, it, vi } from 'vitest' import { packageTarget, parseDesktopPackageInvocation } from '../scripts/package-target.ts' import { withMacOSNotarizationProxy } from '../scripts/macos-notarization-proxy.ts' @@ -44,6 +45,83 @@ function supervisor(failure?: string) { return { run, stages } } +it.each(['pack', 'runtime'] as const)('overlaps Windows preparation and waits for both before signing when %s finishes first', async (first) => { + const { run, stages } = supervisor() + const pack = Promise.withResolvers() + const runtime = Promise.withResolvers() + const finished = new Set() + run.run.mockImplementation(async (stage) => { + stages.push(stage) + if (stage.startsWith('run release:pack --family dsh ')) await pack.promise + if (stage === 'run prepare:runtime --defer-primary-runtime-smoke') { + await runtime.promise + finished.add('runtime') + } + if (stage.startsWith('--dir native/system/packages/entry pack ')) finished.add('pack') + if (stage === 'run sign:primary-runtime') expect([...finished].sort()).toEqual(['pack', 'runtime']) + }) + const operation = packageTarget(parseDesktopPackageInvocation(['win-x64'], 'win32', 'x64'), environment, run) + const outcome = operation.then(() => undefined, (error: unknown) => error) + try { + await vi.waitFor(() => { + expect(stages.some(stage => stage.startsWith('run release:pack --family dsh '))).toBe(true) + expect(stages).toContain('run prepare:runtime --defer-primary-runtime-smoke') + }) + expect(stages).not.toContain('run sign:primary-runtime') + ;(first === 'pack' ? pack : runtime).resolve(undefined) + await vi.waitFor(() => { expect(finished.has(first)).toBe(true) }) + expect(stages).not.toContain('run sign:primary-runtime') + expect(writeFileSync).not.toHaveBeenCalled() + ;(first === 'pack' ? runtime : pack).resolve(undefined) + expect(await outcome).toBeUndefined() + expect(stages).toContain('run sign:primary-runtime') + expect(writeFileSync).toHaveBeenCalledOnce() + } finally { + pack.resolve(undefined) + runtime.resolve(undefined) + await outcome + } +}) + +it.each(['pack', 'runtime'] as const)('drains the other Windows preparation after %s fails without starting later packing or signing', async (failed) => { + const { run, stages } = supervisor() + const pack = Promise.withResolvers() + const runtime = Promise.withResolvers() + const failure = new Error(`${failed} preparation failed`) + let settled = false + run.run.mockImplementation(async (stage) => { + stages.push(stage) + if (stage.startsWith('run release:pack --family dsh ')) await pack.promise + if (stage === 'run prepare:runtime --defer-primary-runtime-smoke') await runtime.promise + }) + const outcome = packageTarget(parseDesktopPackageInvocation(['win-x64'], 'win32', 'x64'), environment, run).then( + () => { settled = true; return undefined }, + (error: unknown) => { settled = true; return error }, + ) + try { + await vi.waitFor(() => { + expect(stages.some(stage => stage.startsWith('run release:pack --family dsh '))).toBe(true) + expect(stages).toContain('run prepare:runtime --defer-primary-runtime-smoke') + }) + ;(failed === 'pack' ? pack : runtime).reject(failure) + await setImmediate() + expect(settled).toBe(false) + expect(writeFileSync).not.toHaveBeenCalled() + ;(failed === 'pack' ? runtime : pack).resolve(undefined) + expect(await outcome).toBe(failure) + expect(stages.filter(stage => stage.startsWith('run release:pack'))).toHaveLength(1) + expect(stages.some(stage => stage.startsWith('--dir apps/desktop-host pack'))).toBe(false) + expect(stages.some(stage => stage.startsWith('--dir native/system'))).toBe(false) + expect(stages.some(stage => stage.startsWith('run sign:primary-runtime'))).toBe(false) + expect(stages.some(stage => stage.startsWith('run prepare:packages') || stage.startsWith('run prepare:dsh'))).toBe(false) + expect(writeFileSync).not.toHaveBeenCalled() + } finally { + pack.resolve(undefined) + runtime.resolve(undefined) + await outcome + } +}) + it.each([undefined, '3'])('passes the package cache and selected compression to their subprocesses (%s)', async (level) => { const { run } = supervisor() const cache = resolve('fixture-package-cache') @@ -162,7 +240,10 @@ it.each([ const invocation = parseDesktopPackageInvocation([target, '--unsigned'], target === 'win-x64' ? 'win32' : 'darwin', target === 'win-x64' ? 'x64' : 'arm64') await expect(packageTarget(invocation, environment, run)).rejects.toThrow('stage refused') - expect(stages.at(-1)?.startsWith(failure)).toBe(true) + const failedAt = stages.findIndex(stage => stage.startsWith(failure)) + expect(failedAt).toBeGreaterThanOrEqual(0) + expect(stages.slice(failedAt + 1)).toEqual( + target === 'win-x64' && failure.startsWith('exec tsx') ? ['run prepare:runtime'] : []) expect(stages.some(stage => stage.startsWith('--dir apps/desktop-host pack'))).toBe(false) expect(writeFileSync).not.toHaveBeenCalled() }) diff --git a/apps/desktop/windows-signing/README.i18n.yaml b/apps/desktop/windows-signing/README.i18n.yaml index dda6a15aa1..84902b410f 100644 --- a/apps/desktop/windows-signing/README.i18n.yaml +++ b/apps/desktop/windows-signing/README.i18n.yaml @@ -33,8 +33,8 @@ en: 5401b605a2f3a44e zh: fc1f6ee667bcd20d /windows-signing-and-runner-operations/signature-cache-and-concurrent-stages: - en: 78593443275fe259 - zh: 9a8c1b541f479273 + en: 6e8208d19ad510e5 + zh: eee42e8938c0f603 /windows-signing-and-runner-operations/verification-limits: en: 94a471410c84a1c7 zh: 115228e4ca36581d diff --git a/apps/desktop/windows-signing/README.md b/apps/desktop/windows-signing/README.md index 2ef710e820..122bb7d705 100644 --- a/apps/desktop/windows-signing/README.md +++ b/apps/desktop/windows-signing/README.md @@ -92,7 +92,7 @@ Cache-hit copying, hashing and per-file candidate trust checks run with `DSH_DES PE discovery reads every regular file's header with at most eight open files, rejects links and malformed Windows executables, and drains active reads and closes before reporting failure. Packaging records `windows:enumerate-code` and `windows:inspect-signatures` as nested phases of signing and artifact verification; their timings are included in the enclosing stages. -Windows packaging reads `DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY` from `.env.windows` or the selected preset: a positive integer, defaulting to `4`. Packaging batches first-party and vendor tarballs with bounded workers, then validates every payload and records publication order. Setting the limit to `1` selects serial packing; standalone `release:pack` defaults to `1`. +Windows packaging reads `DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY` from `.env.windows` or the selected preset: a positive integer, defaulting to `4`. Packaging batches first-party and vendor tarballs with bounded workers, then validates every payload and records publication order. After the complete build succeeds, tarball packing overlaps Electron and primary-runtime preparation in separate output directories. Both must finish successfully before runtime signing; preparation failure stops new packing commands and drains active processes before returning. Setting the limit to `1` selects serial tarball workers; standalone `release:pack` defaults to `1`. Each runtime stage writes `SIGNATURE_CACHE_SUMMARY` to stdout and its packaging journal, including the actual directory, policy identity, hit/miss counts, newly published and retained entries, signing requests, avoided signing requests and validation failures. Timings distinguish signing, trust verification of restored and newly signed files, and restoration; restoration includes its trust check and staging cleanup. These counters exclude preflight, final artifact signing and the outer runtime signature checks. Stage lock events record waiting separately. Concurrent timings sum per-file work rather than stage wall time. diff --git a/apps/desktop/windows-signing/README.zh.md b/apps/desktop/windows-signing/README.zh.md index 7960543a17..243bb0467f 100644 --- a/apps/desktop/windows-signing/README.zh.md +++ b/apps/desktop/windows-signing/README.zh.md @@ -92,7 +92,7 @@ Windows 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `.desktop-build/pac PE 发现会读取每个普通文件的头部,同时最多打开八个文件,拒绝链接和损坏的 Windows 可执行文件,并在报告失败前等待在途读取和关闭结束。打包将 `windows:enumerate-code` 与 `windows:inspect-signatures` 记录为签名和产物验证的嵌套阶段;其耗时已包含在外层阶段中。 -Windows 打包从 `.env.windows` 或所选预设读取 `DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY`:必须为正整数,默认 `4`。Windows 使用有界 worker 批量打包第一方和 vendor tarball,随后校验每个产物并记录发布顺序。设为 `1` 时串行打包;独立的 `release:pack` 默认使用 `1`。 +Windows 打包从 `.env.windows` 或所选预设读取 `DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY`:必须为正整数,默认 `4`。Windows 使用有界 worker 批量打包第一方和 vendor tarball,随后校验每个产物并记录发布顺序。完整构建成功后,tarball 打包与 Electron、主要运行时准备在各自输出目录中并行执行。两者都成功结束后才开始运行时签名;准备失败会停止新的打包命令,并等待在途进程结束后返回。设为 `1` 时 tarball worker 串行执行;独立的 `release:pack` 默认使用 `1`。 每个运行时阶段向标准输出及打包日志写入 `SIGNATURE_CACHE_SUMMARY`,包含实际目录、策略标识、命中及未命中数、新发布及保留项数、签名请求数、省去的签名请求数和验证失败数。计时区分签名、恢复文件和新签名文件的信任验证及恢复;恢复耗时包含其信任检查和暂存清理。这些统计不包含预检、最终产物签名及外层运行时验签。阶段锁事件单独记录等待时间。 并发计时累加逐文件工作耗时,不代表阶段墙钟时间。 From d0664c9a7c9053e0771a77d34e2a9adb9adbf6b4 Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:58:51 +0800 Subject: [PATCH 12/46] perf(windows): match runtime I/O pool to bounded readers --- apps/desktop/scripts/package-target.ts | 1 + .../tests/package-target-stages.spec.ts | 23 +++++++++++++++++++ apps/desktop/windows-signing/README.i18n.yaml | 4 ++-- apps/desktop/windows-signing/README.md | 2 ++ apps/desktop/windows-signing/README.zh.md | 2 ++ 5 files changed, 30 insertions(+), 2 deletions(-) diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index 7a56b52e1b..80954b9c5a 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -441,6 +441,7 @@ export async function packageTarget( DSH_DESKTOP_TARGET_PLATFORM: target.platform, DSH_DESKTOP_TARGET_ARCH: target.arch, } + if (target.platform === 'win32') targetEnv.UV_THREADPOOL_SIZE ??= '8' const downloadEnv = macOSDownloadEnvironment(targetEnv, mac?.downloadProxy) const electronBuilderEnv = desktopElectronBuilderEnvironment(downloadEnv, invocation.unsigned) if (target.platform === 'win32') { diff --git a/apps/desktop/tests/package-target-stages.spec.ts b/apps/desktop/tests/package-target-stages.spec.ts index 091de3c050..2eec7148ca 100644 --- a/apps/desktop/tests/package-target-stages.spec.ts +++ b/apps/desktop/tests/package-target-stages.spec.ts @@ -122,6 +122,29 @@ it.each(['pack', 'runtime'] as const)('drains the other Windows preparation afte } }) +it.each([ + { target: 'win-x64', platform: 'win32', arch: 'x64', pool: undefined, expected: '8' }, + { target: 'win-x64', platform: 'win32', arch: 'x64', pool: '2', expected: '2' }, + { target: 'mac-arm64', platform: 'darwin', arch: 'arm64', pool: undefined, expected: undefined }, + { target: 'mac-arm64', platform: 'darwin', arch: 'arm64', pool: '16', expected: '16' }, +] as const)('selects the I/O pool for $target assembly without changing compiler or pack settings ($pool)', async ({ target, platform, arch, pool, expected }) => { + const { run } = supervisor() + await packageTarget(parseDesktopPackageInvocation([target, '--unsigned'], platform, arch), { + ...environment, ...(pool === undefined ? {} : { UV_THREADPOOL_SIZE: pool }), + }, run) + const preparation = run.run.mock.calls.filter(([stage]) => stage.startsWith('run prepare:')) + const builder = run.run.mock.calls.find(([stage]) => stage.startsWith('exec electron-builder ')) + const smoke = run.run.mock.calls.find(([stage]) => stage.startsWith('exec tsx scripts/smoke-packaged-runtime.ts')) + expect(preparation).toHaveLength(3) + expect(builder).toBeDefined() + expect(smoke).toBeDefined() + for (const call of [...preparation, builder!, smoke!]) expect(call[3].env.UV_THREADPOOL_SIZE).toBe(expected) + const compilationAndPacking = run.run.mock.calls.filter(([stage]) => !stage.startsWith('run prepare:') + && !stage.startsWith('exec electron-builder ') && !stage.startsWith('exec tsx scripts/smoke-packaged-runtime.ts')) + expect(compilationAndPacking.length).toBeGreaterThan(0) + for (const call of compilationAndPacking) expect(call[3].env.UV_THREADPOOL_SIZE).toBe(pool) +}) + it.each([undefined, '3'])('passes the package cache and selected compression to their subprocesses (%s)', async (level) => { const { run } = supervisor() const cache = resolve('fixture-package-cache') diff --git a/apps/desktop/windows-signing/README.i18n.yaml b/apps/desktop/windows-signing/README.i18n.yaml index 84902b410f..23f07f9c90 100644 --- a/apps/desktop/windows-signing/README.i18n.yaml +++ b/apps/desktop/windows-signing/README.i18n.yaml @@ -33,8 +33,8 @@ en: 5401b605a2f3a44e zh: fc1f6ee667bcd20d /windows-signing-and-runner-operations/signature-cache-and-concurrent-stages: - en: 6e8208d19ad510e5 - zh: eee42e8938c0f603 + en: 0abcf9d4186d66ba + zh: b1c934b1697409bd /windows-signing-and-runner-operations/verification-limits: en: 94a471410c84a1c7 zh: 115228e4ca36581d diff --git a/apps/desktop/windows-signing/README.md b/apps/desktop/windows-signing/README.md index 122bb7d705..e16b40aa58 100644 --- a/apps/desktop/windows-signing/README.md +++ b/apps/desktop/windows-signing/README.md @@ -92,6 +92,8 @@ Cache-hit copying, hashing and per-file candidate trust checks run with `DSH_DES PE discovery reads every regular file's header with at most eight open files, rejects links and malformed Windows executables, and drains active reads and closes before reporting failure. Packaging records `windows:enumerate-code` and `windows:inspect-signatures` as nested phases of signing and artifact verification; their timings are included in the enclosing stages. +Windows runtime preparation, signing, installer assembly and verification subprocesses default Node's `UV_THREADPOOL_SIZE` to `8` for file I/O. An explicit environment value takes precedence. Compiler and tarball subprocesses retain their original setting; hardware signing remains serial. + Windows packaging reads `DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY` from `.env.windows` or the selected preset: a positive integer, defaulting to `4`. Packaging batches first-party and vendor tarballs with bounded workers, then validates every payload and records publication order. After the complete build succeeds, tarball packing overlaps Electron and primary-runtime preparation in separate output directories. Both must finish successfully before runtime signing; preparation failure stops new packing commands and drains active processes before returning. Setting the limit to `1` selects serial tarball workers; standalone `release:pack` defaults to `1`. Each runtime stage writes `SIGNATURE_CACHE_SUMMARY` to stdout and its packaging journal, including the actual directory, policy identity, hit/miss counts, newly published and retained entries, signing requests, avoided signing requests and validation failures. Timings distinguish signing, trust verification of restored and newly signed files, and restoration; restoration includes its trust check and staging cleanup. These counters exclude preflight, final artifact signing and the outer runtime signature checks. Stage lock events record waiting separately. Concurrent timings sum per-file work rather than stage wall time. diff --git a/apps/desktop/windows-signing/README.zh.md b/apps/desktop/windows-signing/README.zh.md index 243bb0467f..e4e16b5cf0 100644 --- a/apps/desktop/windows-signing/README.zh.md +++ b/apps/desktop/windows-signing/README.zh.md @@ -92,6 +92,8 @@ Windows 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `.desktop-build/pac PE 发现会读取每个普通文件的头部,同时最多打开八个文件,拒绝链接和损坏的 Windows 可执行文件,并在报告失败前等待在途读取和关闭结束。打包将 `windows:enumerate-code` 与 `windows:inspect-signatures` 记录为签名和产物验证的嵌套阶段;其耗时已包含在外层阶段中。 +Windows 运行时准备、签名、安装器组装与验证子进程默认将 Node 的 `UV_THREADPOOL_SIZE` 设为 `8`,用于文件 I/O。显式环境值优先。编译器和 tarball 子进程保留原有设置;硬件签名仍串行执行。 + Windows 打包从 `.env.windows` 或所选预设读取 `DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY`:必须为正整数,默认 `4`。Windows 使用有界 worker 批量打包第一方和 vendor tarball,随后校验每个产物并记录发布顺序。完整构建成功后,tarball 打包与 Electron、主要运行时准备在各自输出目录中并行执行。两者都成功结束后才开始运行时签名;准备失败会停止新的打包命令,并等待在途进程结束后返回。设为 `1` 时 tarball worker 串行执行;独立的 `release:pack` 默认使用 `1`。 每个运行时阶段向标准输出及打包日志写入 `SIGNATURE_CACHE_SUMMARY`,包含实际目录、策略标识、命中及未命中数、新发布及保留项数、签名请求数、省去的签名请求数和验证失败数。计时区分签名、恢复文件和新签名文件的信任验证及恢复;恢复耗时包含其信任检查和暂存清理。这些统计不包含预检、最终产物签名及外层运行时验签。阶段锁事件单独记录等待时间。 并发计时累加逐文件工作耗时,不代表阶段墙钟时间。 From a12c0dfe46ea77a0a7f0d56fd8f5f1eb6e66485e Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:33:54 +0800 Subject: [PATCH 13/46] revert(windows): drop unproven packaging I/O pool default --- apps/desktop/scripts/package-target.ts | 1 - .../tests/package-target-stages.spec.ts | 23 ------------------- apps/desktop/windows-signing/README.i18n.yaml | 4 ++-- apps/desktop/windows-signing/README.md | 2 -- apps/desktop/windows-signing/README.zh.md | 2 -- 5 files changed, 2 insertions(+), 30 deletions(-) diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index 80954b9c5a..7a56b52e1b 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -441,7 +441,6 @@ export async function packageTarget( DSH_DESKTOP_TARGET_PLATFORM: target.platform, DSH_DESKTOP_TARGET_ARCH: target.arch, } - if (target.platform === 'win32') targetEnv.UV_THREADPOOL_SIZE ??= '8' const downloadEnv = macOSDownloadEnvironment(targetEnv, mac?.downloadProxy) const electronBuilderEnv = desktopElectronBuilderEnvironment(downloadEnv, invocation.unsigned) if (target.platform === 'win32') { diff --git a/apps/desktop/tests/package-target-stages.spec.ts b/apps/desktop/tests/package-target-stages.spec.ts index 2eec7148ca..091de3c050 100644 --- a/apps/desktop/tests/package-target-stages.spec.ts +++ b/apps/desktop/tests/package-target-stages.spec.ts @@ -122,29 +122,6 @@ it.each(['pack', 'runtime'] as const)('drains the other Windows preparation afte } }) -it.each([ - { target: 'win-x64', platform: 'win32', arch: 'x64', pool: undefined, expected: '8' }, - { target: 'win-x64', platform: 'win32', arch: 'x64', pool: '2', expected: '2' }, - { target: 'mac-arm64', platform: 'darwin', arch: 'arm64', pool: undefined, expected: undefined }, - { target: 'mac-arm64', platform: 'darwin', arch: 'arm64', pool: '16', expected: '16' }, -] as const)('selects the I/O pool for $target assembly without changing compiler or pack settings ($pool)', async ({ target, platform, arch, pool, expected }) => { - const { run } = supervisor() - await packageTarget(parseDesktopPackageInvocation([target, '--unsigned'], platform, arch), { - ...environment, ...(pool === undefined ? {} : { UV_THREADPOOL_SIZE: pool }), - }, run) - const preparation = run.run.mock.calls.filter(([stage]) => stage.startsWith('run prepare:')) - const builder = run.run.mock.calls.find(([stage]) => stage.startsWith('exec electron-builder ')) - const smoke = run.run.mock.calls.find(([stage]) => stage.startsWith('exec tsx scripts/smoke-packaged-runtime.ts')) - expect(preparation).toHaveLength(3) - expect(builder).toBeDefined() - expect(smoke).toBeDefined() - for (const call of [...preparation, builder!, smoke!]) expect(call[3].env.UV_THREADPOOL_SIZE).toBe(expected) - const compilationAndPacking = run.run.mock.calls.filter(([stage]) => !stage.startsWith('run prepare:') - && !stage.startsWith('exec electron-builder ') && !stage.startsWith('exec tsx scripts/smoke-packaged-runtime.ts')) - expect(compilationAndPacking.length).toBeGreaterThan(0) - for (const call of compilationAndPacking) expect(call[3].env.UV_THREADPOOL_SIZE).toBe(pool) -}) - it.each([undefined, '3'])('passes the package cache and selected compression to their subprocesses (%s)', async (level) => { const { run } = supervisor() const cache = resolve('fixture-package-cache') diff --git a/apps/desktop/windows-signing/README.i18n.yaml b/apps/desktop/windows-signing/README.i18n.yaml index 23f07f9c90..84902b410f 100644 --- a/apps/desktop/windows-signing/README.i18n.yaml +++ b/apps/desktop/windows-signing/README.i18n.yaml @@ -33,8 +33,8 @@ en: 5401b605a2f3a44e zh: fc1f6ee667bcd20d /windows-signing-and-runner-operations/signature-cache-and-concurrent-stages: - en: 0abcf9d4186d66ba - zh: b1c934b1697409bd + en: 6e8208d19ad510e5 + zh: eee42e8938c0f603 /windows-signing-and-runner-operations/verification-limits: en: 94a471410c84a1c7 zh: 115228e4ca36581d diff --git a/apps/desktop/windows-signing/README.md b/apps/desktop/windows-signing/README.md index e16b40aa58..122bb7d705 100644 --- a/apps/desktop/windows-signing/README.md +++ b/apps/desktop/windows-signing/README.md @@ -92,8 +92,6 @@ Cache-hit copying, hashing and per-file candidate trust checks run with `DSH_DES PE discovery reads every regular file's header with at most eight open files, rejects links and malformed Windows executables, and drains active reads and closes before reporting failure. Packaging records `windows:enumerate-code` and `windows:inspect-signatures` as nested phases of signing and artifact verification; their timings are included in the enclosing stages. -Windows runtime preparation, signing, installer assembly and verification subprocesses default Node's `UV_THREADPOOL_SIZE` to `8` for file I/O. An explicit environment value takes precedence. Compiler and tarball subprocesses retain their original setting; hardware signing remains serial. - Windows packaging reads `DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY` from `.env.windows` or the selected preset: a positive integer, defaulting to `4`. Packaging batches first-party and vendor tarballs with bounded workers, then validates every payload and records publication order. After the complete build succeeds, tarball packing overlaps Electron and primary-runtime preparation in separate output directories. Both must finish successfully before runtime signing; preparation failure stops new packing commands and drains active processes before returning. Setting the limit to `1` selects serial tarball workers; standalone `release:pack` defaults to `1`. Each runtime stage writes `SIGNATURE_CACHE_SUMMARY` to stdout and its packaging journal, including the actual directory, policy identity, hit/miss counts, newly published and retained entries, signing requests, avoided signing requests and validation failures. Timings distinguish signing, trust verification of restored and newly signed files, and restoration; restoration includes its trust check and staging cleanup. These counters exclude preflight, final artifact signing and the outer runtime signature checks. Stage lock events record waiting separately. Concurrent timings sum per-file work rather than stage wall time. diff --git a/apps/desktop/windows-signing/README.zh.md b/apps/desktop/windows-signing/README.zh.md index e4e16b5cf0..243bb0467f 100644 --- a/apps/desktop/windows-signing/README.zh.md +++ b/apps/desktop/windows-signing/README.zh.md @@ -92,8 +92,6 @@ Windows 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `.desktop-build/pac PE 发现会读取每个普通文件的头部,同时最多打开八个文件,拒绝链接和损坏的 Windows 可执行文件,并在报告失败前等待在途读取和关闭结束。打包将 `windows:enumerate-code` 与 `windows:inspect-signatures` 记录为签名和产物验证的嵌套阶段;其耗时已包含在外层阶段中。 -Windows 运行时准备、签名、安装器组装与验证子进程默认将 Node 的 `UV_THREADPOOL_SIZE` 设为 `8`,用于文件 I/O。显式环境值优先。编译器和 tarball 子进程保留原有设置;硬件签名仍串行执行。 - Windows 打包从 `.env.windows` 或所选预设读取 `DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY`:必须为正整数,默认 `4`。Windows 使用有界 worker 批量打包第一方和 vendor tarball,随后校验每个产物并记录发布顺序。完整构建成功后,tarball 打包与 Electron、主要运行时准备在各自输出目录中并行执行。两者都成功结束后才开始运行时签名;准备失败会停止新的打包命令,并等待在途进程结束后返回。设为 `1` 时 tarball worker 串行执行;独立的 `release:pack` 默认使用 `1`。 每个运行时阶段向标准输出及打包日志写入 `SIGNATURE_CACHE_SUMMARY`,包含实际目录、策略标识、命中及未命中数、新发布及保留项数、签名请求数、省去的签名请求数和验证失败数。计时区分签名、恢复文件和新签名文件的信任验证及恢复;恢复耗时包含其信任检查和暂存清理。这些统计不包含预检、最终产物签名及外层运行时验签。阶段锁事件单独记录等待时间。 并发计时累加逐文件工作耗时,不代表阶段墙钟时间。 From 9ee94429b962d7eb405a869757afa158bf9fb73f Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:47:05 +0800 Subject: [PATCH 14/46] perf(windows): support isolated packaging workspaces --- .github/workflows/windows-package.yml | 29 +++++- apps/desktop/packaging/README.i18n.yaml | 4 +- apps/desktop/packaging/README.md | 2 + apps/desktop/packaging/README.zh.md | 2 + .../scripts/windows-build-workspace.d.mts | 19 ++++ .../scripts/windows-build-workspace.mjs | 94 +++++++++++++++++++ scripts/windows-build-workspace.spec.ts | 63 +++++++++++++ scripts/windows-package-workflow.spec.ts | 55 ++++++++++- 8 files changed, 262 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/scripts/windows-build-workspace.d.mts create mode 100644 apps/desktop/scripts/windows-build-workspace.mjs create mode 100644 scripts/windows-build-workspace.spec.ts diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index 70ed365682..e61bef0640 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -47,6 +47,11 @@ on: type: string default: '' required: false + build_root: + description: '独立构建根目录:填写绝对路径,每次任务创建独立源码副本;留空使用 runner 工作区' + type: string + default: '' + required: false permissions: contents: read @@ -71,6 +76,7 @@ jobs: PACKAGE_TEST_RELEASE_ID: ${{ inputs.test_release_id }} PACKAGE_SIGNING: ${{ inputs.signing }} PACKAGE_CACHE_NAMESPACE: ${{ inputs.cache_namespace }} + PACKAGE_BUILD_ROOT: ${{ inputs.build_root }} steps: - name: Reset packaging checkout working-directory: ${{ runner.temp }} @@ -113,6 +119,17 @@ jobs: - uses: actions/setup-node@v7.0.0 with: node-version: '24' + - name: Select packaging workspace + id: build_workspace + env: + PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }} + run: | + if (!$env:PACKAGE_BUILD_ROOT) { + "directory=$env:GITHUB_WORKSPACE" >> $env:GITHUB_OUTPUT + } else { + node apps/desktop/scripts/windows-build-workspace.mjs + exit $LASTEXITCODE + } - name: Configure persistent pnpm store # PNPM_HOME belongs to this run; keep downloaded packages outside its temporary directory. env: @@ -129,6 +146,7 @@ jobs: "DSH_DESKTOP_PACKAGE_CACHE_DIR=$env:DSH_DESKTOP_PACKAGE_CACHE_DIR" >> $env:GITHUB_ENV "PNPM_CONFIG_STORE_DIR=$storeRoot" >> $env:GITHUB_ENV - name: Install locked dependencies + working-directory: ${{ steps.build_workspace.outputs.directory }} run: | pnpm store path if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } @@ -136,10 +154,12 @@ jobs: exit $LASTEXITCODE - name: Validate preset and resolve build version id: settings + working-directory: ${{ steps.build_workspace.outputs.directory }} env: PACKAGE_BUILD_VERSION: ${{ inputs.build_version }} run: node apps/desktop/scripts/prepare-windows-ci.mjs - name: Check packaging configuration and toolchain + working-directory: ${{ steps.build_workspace.outputs.directory }} env: PACKAGE_VERSION: ${{ steps.settings.outputs.version }} run: | @@ -148,6 +168,7 @@ jobs: pnpm @packageArgs exit $LASTEXITCODE - name: Build installer and verify packaged runtime + working-directory: ${{ steps.build_workspace.outputs.directory }} env: PACKAGE_VERSION: ${{ steps.settings.outputs.version }} run: | @@ -157,6 +178,7 @@ jobs: exit $LASTEXITCODE - name: Retain complete package outside the checkout id: retained + working-directory: ${{ steps.build_workspace.outputs.directory }} env: PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }} PACKAGE_VERSION: ${{ steps.settings.outputs.version }} @@ -167,7 +189,7 @@ jobs: with: name: windows-package-logs-${{ github.run_id }}-${{ github.run_attempt }} include-hidden-files: true - path: apps/desktop/.desktop-build/packaging-runs/ + path: ${{ steps.build_workspace.outputs.directory || github.workspace }}/apps/desktop/.desktop-build/packaging-runs/ if-no-files-found: warn retention-days: 14 - name: Retain verified unsigned installer @@ -188,3 +210,8 @@ jobs: path: ${{ steps.retained.outputs.directory }}/* if-no-files-found: error retention-days: 14 + - name: Remove isolated packaging workspace + if: success() && inputs.build_root != '' + env: + PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }} + run: node apps/desktop/scripts/windows-build-workspace.mjs --remove diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index 7542fbf49d..e432323ac3 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -24,8 +24,8 @@ en: 5b4f0f80a117f202 zh: ff05be196c181087 /desktop-packaging/windows-actions-packaging: - en: 79f09d4deac94041 - zh: 6e0cfbda4c2f6518 + en: 68d800ed10cac4a6 + zh: f72ac7e939af7ad5 /desktop-packaging/macos-self-hosted-packaging: en: a3af9523fdc60036 zh: 5e18d4740e9b7f57 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index ffed19d45c..ccba7ac5ff 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -113,6 +113,8 @@ Select the workflow in Actions, choose the source branch, preset, signing mode a Open the completed run in Actions and download its Artifacts. `windows-x64-signed---` and `windows-x64-unsigned---` contain the corresponding EXE; `windows-package-logs--` contains packaging diagnostics. The upload steps are `Retain verified signed installer`, `Retain verified unsigned installer`, and `Retain packaging diagnostics`; their step durations and timestamped logs show upload time. These artifacts are not a Release or download-site publication. +For path-sensitive Windows build measurements, `build_root` optionally selects an absolute directory outside existing Git checkouts. Each run clones the validated source commit into a new `windows-package--/source` directory; ignored files and previous build outputs are not copied. Dependency and signing caches retain their existing locations. Successful runs remove only their owned checkout after artifact uploads; failed runs retain it for inspection. Leave the input empty to build in the original Actions checkout. Include checkout creation and cleanup in the complete Job timing. + For cache measurements, `cache_namespace` selects a retained subdirectory for both dependency stores and upstream downloads; leave it empty for normal shared caches. Use a new name for a cold sample and repeat that name for warm samples. Names contain 1–64 lowercase letters, digits or hyphens, start with a letter or digit, and cannot be Windows device names. This does not reset the separate signature cache or OS file cache; report those states and Job start-to-completion time alongside phase timings and artifact size. diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index d3b5e2c241..c07f58026c 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -113,6 +113,8 @@ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-s 在 Actions 打开完成的运行并下载 Artifacts。`windows-x64-signed---` 与 `windows-x64-unsigned---` 包含对应 EXE;`windows-package-logs--` 包含打包诊断。上传步骤名为 `Retain verified signed installer`、`Retain verified unsigned installer` 和 `Retain packaging diagnostics`;步骤耗时与带时间戳的日志显示上传用时。这些产物不等于发布 Release 或下载站内容。 +测量 Windows 构建路径对性能的影响时,可用 `build_root` 指定现有 Git checkout 之外的绝对目录。每轮任务将已验证的源码提交克隆到新的 `windows-package--/source` 目录;不会复制忽略文件或此前的构建输出。依赖缓存和签名缓存沿用原有位置。成功任务在产物上传后仅删除本轮所属的 checkout;失败任务保留该目录供检查。输入留空时使用原 Actions checkout。完整 Job 耗时须包含 checkout 创建和清理。 + 测量缓存时,`cache_namespace` 为两个依赖 store 和上游下载选择保留的子目录;留空使用正常共享缓存。新名称用于冷缓存采样,再次使用同名用于热缓存采样。名称由 1–64 个小写字母、数字或连字符组成,以字母或数字开头,且不能为 Windows 设备名。这不会重置独立的签名缓存或操作系统文件缓存;应将其状态、Job 开始至完成的总耗时与阶段耗时和产物体积一并报告。 diff --git a/apps/desktop/scripts/windows-build-workspace.d.mts b/apps/desktop/scripts/windows-build-workspace.d.mts new file mode 100644 index 0000000000..a9037d78c1 --- /dev/null +++ b/apps/desktop/scripts/windows-build-workspace.d.mts @@ -0,0 +1,19 @@ +/** Identity of a Windows packaging run and its independent build root. */ +export interface WindowsBuildWorkspaceOptions { + sourceDirectory: string + buildRoot: string + runId: string + runAttempt: string + sourceSha: string +} +/** + * Clone the clean selected commit into a new run-owned directory. + * @param options - Original checkout and Actions run identity. + * @returns Checkout to use for packaging. + */ +export function prepareWindowsBuildWorkspace(options: WindowsBuildWorkspaceOptions): { directory: string } +/** + * Remove the directory only when its ownership record matches the run. + * @param options - Original checkout and Actions run identity. + */ +export function removeWindowsBuildWorkspace(options: WindowsBuildWorkspaceOptions): void diff --git a/apps/desktop/scripts/windows-build-workspace.mjs b/apps/desktop/scripts/windows-build-workspace.mjs new file mode 100644 index 0000000000..a15ade90dd --- /dev/null +++ b/apps/desktop/scripts/windows-build-workspace.mjs @@ -0,0 +1,94 @@ +/** Create and remove a run-owned checkout for Windows packaging. */ +import { execFileSync } from 'node:child_process' +import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, isAbsolute, join, parse, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +function assertUnlinkedAncestors(directory) { + for (let current = directory; ; current = dirname(current)) { + if (existsSync(current) && lstatSync(current).isSymbolicLink()) { + throw new Error('Packaging workspace ancestors must not be links') + } + if (dirname(current) === current) break + } +} + +function locations(options) { + const { buildRoot, runId, runAttempt, sourceSha } = options + if (!buildRoot || !isAbsolute(buildRoot) || /[\r\n\0]/.test(buildRoot)) { + throw new Error('Packaging build root must be a nonempty absolute directory') + } + const root = resolve(buildRoot) + if (root === parse(root).root) throw new Error('Packaging build root must not be a volume root') + if (!/^[1-9]\d*$/.test(runId) || !/^[1-9]\d*$/.test(runAttempt) || !/^[a-f0-9]{40}$/.test(sourceSha)) { + throw new Error('Packaging workspace requires a run identity and exact commit') + } + assertUnlinkedAncestors(root) + for (let current = root; ; current = dirname(current)) { + if (existsSync(join(current, '.git'))) throw new Error('Packaging build root must be outside existing Git checkouts') + if (dirname(current) === current) break + } + const owner = join(root, `windows-package-${runId}-${runAttempt}`) + assertUnlinkedAncestors(owner) + return { root, owner, directory: join(owner, 'source') } +} + +function git(directory, args) { + return execFileSync('git', args, { cwd: directory, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() +} + +function ownership(options, paths) { + return JSON.stringify({ version: 1, root: paths.root, source: resolve(options.sourceDirectory), runId: options.runId, runAttempt: options.runAttempt, sourceSha: options.sourceSha }) +} + +/** + * Clone the validated commit into a new run-owned checkout; failures retain diagnostics. + * @param {{sourceDirectory: string, buildRoot: string, runId: string, runAttempt: string, sourceSha: string}} options - Source and run identity. + * @returns {{directory: string}} Checkout to use for packaging. + */ +export function prepareWindowsBuildWorkspace(options) { + const paths = locations(options) + if (git(options.sourceDirectory, ['rev-parse', 'HEAD']) !== options.sourceSha || git(options.sourceDirectory, ['status', '--porcelain'])) { + throw new Error('Packaging source must be clean and match the selected commit') + } + mkdirSync(paths.root, { recursive: true }) + mkdirSync(paths.owner) + writeFileSync(join(paths.owner, 'owner.json'), ownership(options, paths), { flag: 'wx' }) + git(paths.owner, ['clone', '--no-hardlinks', '--no-checkout', resolve(options.sourceDirectory), paths.directory]) + git(paths.directory, ['checkout', '--detach', options.sourceSha]) + if (git(paths.directory, ['rev-parse', 'HEAD']) !== options.sourceSha || git(paths.directory, ['status', '--porcelain'])) { + throw new Error('Isolated packaging checkout does not match the selected commit') + } + return { directory: paths.directory } +} + +/** + * Remove only the matching run-owned directory after successful artifact upload. + * @param {{sourceDirectory: string, buildRoot: string, runId: string, runAttempt: string, sourceSha: string}} options - Original source and run identity. + * @returns {void} + */ +export function removeWindowsBuildWorkspace(options) { + const paths = locations(options) + const marker = join(paths.owner, 'owner.json') + const stat = lstatSync(marker) + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 8192 || readFileSync(marker, 'utf8') !== ownership(options, paths)) { + throw new Error('Packaging workspace ownership does not match this run') + } + rmSync(paths.owner, { recursive: true }) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const options = { + sourceDirectory: process.env.GITHUB_WORKSPACE ?? '', + buildRoot: process.env.PACKAGE_BUILD_ROOT ?? '', + runId: process.env.GITHUB_RUN_ID ?? '', + runAttempt: process.env.GITHUB_RUN_ATTEMPT ?? '', + sourceSha: process.env.PACKAGE_SOURCE_SHA ?? '', + } + if (process.argv[2] === '--remove') removeWindowsBuildWorkspace(options) + else { + if (process.argv.length !== 2 || !process.env.GITHUB_OUTPUT) throw new Error('Expected Actions output file and no arguments') + const result = prepareWindowsBuildWorkspace(options) + appendFileSync(process.env.GITHUB_OUTPUT, `directory=${result.directory}\n`) + } +} diff --git a/scripts/windows-build-workspace.spec.ts b/scripts/windows-build-workspace.spec.ts new file mode 100644 index 0000000000..f67ee577d5 --- /dev/null +++ b/scripts/windows-build-workspace.spec.ts @@ -0,0 +1,63 @@ +import { execFileSync } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { expect, it } from 'vitest' +import { prepareWindowsBuildWorkspace, removeWindowsBuildWorkspace } from '../apps/desktop/scripts/windows-build-workspace.mjs' + +it('clones the exact clean commit and removes only its run-owned checkout', () => { + const root = mkdtempSync(join(tmpdir(), 'package-workspace-')) + try { + const sourceDirectory = join(root, 'original') + mkdirSync(sourceDirectory) + const git = (...args: string[]) => execFileSync('git', args, { cwd: sourceDirectory, encoding: 'utf8', stdio: 'pipe' }).trim() + git('init') + writeFileSync(join(sourceDirectory, 'source.txt'), 'selected source') + writeFileSync(join(sourceDirectory, '.gitignore'), 'private.env\n') + git('add', '.') + git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '-m', 'fixture') + writeFileSync(join(sourceDirectory, 'private.env'), 'ignored fixture') + const options = { sourceDirectory, buildRoot: join(root, 'builds'), runId: '123', runAttempt: '1', sourceSha: git('rev-parse', 'HEAD') } + expect(() => prepareWindowsBuildWorkspace({ ...options, buildRoot: sourceDirectory })).toThrow('outside existing Git') + expect(() => prepareWindowsBuildWorkspace({ ...options, buildRoot: 'relative' })).toThrow('absolute') + expect(() => prepareWindowsBuildWorkspace({ ...options, runId: '../other' })).toThrow('identity') + expect(() => prepareWindowsBuildWorkspace({ ...options, sourceSha: '0'.repeat(40) })).toThrow('match') + writeFileSync(join(sourceDirectory, 'source.txt'), 'dirty') + expect(() => prepareWindowsBuildWorkspace(options)).toThrow('clean') + writeFileSync(join(sourceDirectory, 'source.txt'), 'selected source') + const output = join(root, 'actions-output') + execFileSync(process.execPath, [resolve(import.meta.dirname, '../apps/desktop/scripts/windows-build-workspace.mjs')], { + cwd: sourceDirectory, + env: { + ...process.env, + GITHUB_WORKSPACE: sourceDirectory, + PACKAGE_BUILD_ROOT: options.buildRoot, + GITHUB_RUN_ID: options.runId, + GITHUB_RUN_ATTEMPT: options.runAttempt, + PACKAGE_SOURCE_SHA: options.sourceSha, + GITHUB_OUTPUT: output, + }, + stdio: 'pipe', + }) + const directory = join(options.buildRoot, 'windows-package-123-1', 'source') + expect(readFileSync(output, 'utf8')).toBe(`directory=${directory}\n`) + expect(readFileSync(join(directory, 'source.txt'), 'utf8')).toBe('selected source') + expect(existsSync(join(directory, 'private.env'))).toBe(false) + expect(() => prepareWindowsBuildWorkspace(options)).toThrow() + expect(() => { removeWindowsBuildWorkspace({ ...options, sourceSha: '0'.repeat(40) }) }).toThrow('ownership') + expect(existsSync(directory)).toBe(true) + const external = join(root, 'external') + mkdirSync(external) + writeFileSync(join(external, 'keep.txt'), 'keep') + symlinkSync(external, join(directory, 'node_modules'), process.platform === 'win32' ? 'junction' : 'dir') + removeWindowsBuildWorkspace(options) + expect(existsSync(directory)).toBe(false) + expect(readFileSync(join(external, 'keep.txt'), 'utf8')).toBe('keep') + expect(readFileSync(join(sourceDirectory, 'source.txt'), 'utf8')).toBe('selected source') + const linked = join(root, 'linked') + symlinkSync(external, linked, process.platform === 'win32' ? 'junction' : 'dir') + expect(() => prepareWindowsBuildWorkspace({ ...options, buildRoot: join(linked, 'builds') })).toThrow('links') + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/scripts/windows-package-workflow.spec.ts b/scripts/windows-package-workflow.spec.ts index 395fe48a4e..bf2109c6b4 100644 --- a/scripts/windows-package-workflow.spec.ts +++ b/scripts/windows-package-workflow.spec.ts @@ -7,7 +7,7 @@ import { expect, it } from 'vitest' const root = resolve(import.meta.dirname, '..') const workflow = yaml.load(readFileSync(resolve(root, '.github/workflows/windows-package.yml'), 'utf8')) as { - on: Record }> + on: Record }> permissions: Record concurrency: { group: string; 'cancel-in-progress': boolean } jobs: Record with?: Record }> @@ -67,7 +68,7 @@ it('routes manual packaging to the dedicated runner with explicit preset and sig expect(workflow.concurrency).toEqual({ group: 'windows-desktop-package', 'cancel-in-progress': false }) expect(workflow.permissions).toEqual({ contents: 'read' }) expect(Object.keys(workflow.on.workflow_dispatch!.inputs).sort()).toEqual([ - 'build_version', 'cache_namespace', 'preset', 'signing', 'source_ref', 'test_release_id', 'update_channel', 'version_mode', + 'build_root', 'build_version', 'cache_namespace', 'preset', 'signing', 'source_ref', 'test_release_id', 'update_channel', 'version_mode', ]) expect(workflow.on.workflow_dispatch!.inputs.signing!.default).toBe('signed') expect(workflow.on.workflow_dispatch!.inputs.build_version!.required).toBe(false) @@ -104,13 +105,61 @@ it('retains failure diagnostics and uploads only the selected successful install const uploads = job.steps.filter(step => step.uses?.startsWith('actions/upload-artifact@')) expect(uploads).toHaveLength(3) expect(uploads[0]!.if).toBe("always() && steps.checkout.outcome == 'success'") - expect(uploads[0]!.with).toMatchObject({ path: 'apps/desktop/.desktop-build/packaging-runs/', 'include-hidden-files': true }) + expect(uploads[0]!.with).toMatchObject({ + path: '${{ steps.build_workspace.outputs.directory || github.workspace }}/apps/desktop/.desktop-build/packaging-runs/', + 'include-hidden-files': true, + }) for (const mode of ['unsigned', 'signed']) { const upload = uploads.find(step => step.if === `success() && inputs.signing == '${mode}'`)! expect(upload.with).toMatchObject({ path: '${{ steps.retained.outputs.directory }}/*', 'if-no-files-found': 'error' }) } }) +it('routes all packaging consumers to the selected workspace and removes it only after successful uploads', () => { + expect(workflow.on.workflow_dispatch!.inputs.build_root).toMatchObject({ type: 'string', default: '', required: false }) + expect(job.env.PACKAGE_BUILD_ROOT).toBe('${{ inputs.build_root }}') + const selected = job.steps.find(step => step.id === 'build_workspace')! + expect(selected.name).toBe('Select packaging workspace') + expect(selected.env).toEqual({ PACKAGE_SOURCE_SHA: '${{ steps.checkout.outputs.commit }}' }) + expect(selected['working-directory']).toBeUndefined() + expect(selected.run).toContain('if (!$env:PACKAGE_BUILD_ROOT)') + expect(selected.run).toContain('"directory=$env:GITHUB_WORKSPACE" >> $env:GITHUB_OUTPUT') + expect(selected.run).toContain('} else {') + expect(selected.run).toContain('node apps/desktop/scripts/windows-build-workspace.mjs') + expect(selected.run).toContain('exit $LASTEXITCODE') + const index = job.steps.indexOf(selected) + expect(index).toBeGreaterThan(job.steps.findIndex(step => step.uses?.startsWith('actions/setup-node@'))) + expect(index).toBeLessThan(job.steps.findIndex(step => step.name === 'Configure persistent pnpm store')) + const consumers = [ + 'Install locked dependencies', + 'Validate preset and resolve build version', + 'Check packaging configuration and toolchain', + 'Build installer and verify packaged runtime', + 'Retain complete package outside the checkout', + ] + for (const name of consumers) { + const step = job.steps.find(entry => entry.name === name)! + expect(step['working-directory']).toBe('${{ steps.build_workspace.outputs.directory }}') + expect(job.steps.indexOf(step)).toBeGreaterThan(index) + } + expect(job.steps.filter(step => step['working-directory'] === '${{ steps.build_workspace.outputs.directory }}')) + .toHaveLength(consumers.length) + const store = job.steps.find(step => step.name === 'Configure persistent pnpm store')! + expect(store.run).toContain('[IO.Path]::GetPathRoot($env:GITHUB_WORKSPACE)') + expect(store['working-directory']).toBeUndefined() + const cleanup = job.steps.at(-1)! + expect(cleanup).toMatchObject({ + name: 'Remove isolated packaging workspace', + if: "success() && inputs.build_root != ''", + env: { PACKAGE_SOURCE_SHA: '${{ steps.checkout.outputs.commit }}' }, + run: 'node apps/desktop/scripts/windows-build-workspace.mjs --remove', + }) + expect(cleanup['working-directory']).toBeUndefined() + for (const upload of job.steps.filter(step => step.uses?.startsWith('actions/upload-artifact@'))) { + expect(job.steps.indexOf(upload)).toBeLessThan(job.steps.indexOf(cleanup)) + } +}) + it.skipIf(process.platform !== 'win32')('keeps the dependency store stable across run-specific pnpm homes', () => { const directory = mkdtempSync(resolve(tmpdir(), 'windows-package-store-')) try { From 122da6fcff2fec00b4a87de59d4b5c2cf8f23d42 Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:10:29 +0800 Subject: [PATCH 15/46] perf(windows): isolate packaging temporary files --- .github/workflows/windows-package.yml | 5 ++++ apps/desktop/packaging/README.i18n.yaml | 4 +-- apps/desktop/packaging/README.md | 2 +- apps/desktop/packaging/README.zh.md | 2 +- .../scripts/windows-build-workspace.d.mts | 4 +-- .../scripts/windows-build-workspace.mjs | 9 ++++--- scripts/windows-build-workspace.spec.ts | 5 +++- scripts/windows-package-workflow.spec.ts | 27 +++++++++++++++++++ 8 files changed, 47 insertions(+), 11 deletions(-) diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index e61bef0640..36dcc0a85d 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -171,7 +171,12 @@ jobs: working-directory: ${{ steps.build_workspace.outputs.directory }} env: PACKAGE_VERSION: ${{ steps.settings.outputs.version }} + PACKAGE_BUILD_TEMP: ${{ steps.build_workspace.outputs.temporaryDirectory }} run: | + if ($env:PACKAGE_BUILD_TEMP) { + $env:TEMP = $env:PACKAGE_BUILD_TEMP + $env:TMP = $env:PACKAGE_BUILD_TEMP + } $packageArgs = @('run', 'package:desktop:win:x64', '--build-version', $env:PACKAGE_VERSION) if ($env:PACKAGE_SIGNING -eq 'unsigned') { $packageArgs += '--unsigned' } pnpm @packageArgs diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index e432323ac3..3aea527fec 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -24,8 +24,8 @@ en: 5b4f0f80a117f202 zh: ff05be196c181087 /desktop-packaging/windows-actions-packaging: - en: 68d800ed10cac4a6 - zh: f72ac7e939af7ad5 + en: faf2b4cffb576757 + zh: b9c807eea360d53d /desktop-packaging/macos-self-hosted-packaging: en: a3af9523fdc60036 zh: 5e18d4740e9b7f57 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index ccba7ac5ff..211dac96e5 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -113,7 +113,7 @@ Select the workflow in Actions, choose the source branch, preset, signing mode a Open the completed run in Actions and download its Artifacts. `windows-x64-signed---` and `windows-x64-unsigned---` contain the corresponding EXE; `windows-package-logs--` contains packaging diagnostics. The upload steps are `Retain verified signed installer`, `Retain verified unsigned installer`, and `Retain packaging diagnostics`; their step durations and timestamped logs show upload time. These artifacts are not a Release or download-site publication. -For path-sensitive Windows build measurements, `build_root` optionally selects an absolute directory outside existing Git checkouts. Each run clones the validated source commit into a new `windows-package--/source` directory; ignored files and previous build outputs are not copied. Dependency and signing caches retain their existing locations. Successful runs remove only their owned checkout after artifact uploads; failed runs retain it for inspection. Leave the input empty to build in the original Actions checkout. Include checkout creation and cleanup in the complete Job timing. +For path-sensitive Windows build measurements, `build_root` optionally selects an absolute directory outside existing Git checkouts. Each run clones the validated source commit into a new `windows-package--/source` directory; ignored files and previous build outputs are not copied. Only the packaging process uses a sibling run-owned `temp` directory through `TEMP` and `TMP`; Actions steps and post-job hooks retain their original temporary directories. Dependency and signing caches retain their existing locations. Successful runs remove only their owned checkout after artifact uploads; failed runs retain it for inspection. Leave the input empty to build in the original Actions checkout. Include checkout creation and cleanup in the complete Job timing. For cache measurements, `cache_namespace` selects a retained subdirectory for both dependency stores and upstream downloads; leave it empty for normal shared caches. Use a new name for a cold sample and repeat that name for warm samples. Names contain 1–64 lowercase letters, digits or hyphens, start with a letter or digit, and cannot be Windows device names. This does not reset the separate signature cache or OS file cache; report those states and Job start-to-completion time alongside phase timings and artifact size. diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index c07f58026c..154aac9ca4 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -113,7 +113,7 @@ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-s 在 Actions 打开完成的运行并下载 Artifacts。`windows-x64-signed---` 与 `windows-x64-unsigned---` 包含对应 EXE;`windows-package-logs--` 包含打包诊断。上传步骤名为 `Retain verified signed installer`、`Retain verified unsigned installer` 和 `Retain packaging diagnostics`;步骤耗时与带时间戳的日志显示上传用时。这些产物不等于发布 Release 或下载站内容。 -测量 Windows 构建路径对性能的影响时,可用 `build_root` 指定现有 Git checkout 之外的绝对目录。每轮任务将已验证的源码提交克隆到新的 `windows-package--/source` 目录;不会复制忽略文件或此前的构建输出。依赖缓存和签名缓存沿用原有位置。成功任务在产物上传后仅删除本轮所属的 checkout;失败任务保留该目录供检查。输入留空时使用原 Actions checkout。完整 Job 耗时须包含 checkout 创建和清理。 +测量 Windows 构建路径对性能的影响时,可用 `build_root` 指定现有 Git checkout 之外的绝对目录。每轮任务将已验证的源码提交克隆到新的 `windows-package--/source` 目录;不会复制忽略文件或此前的构建输出。仅打包进程通过 `TEMP` 和 `TMP` 使用同轮所属的同级 `temp` 目录;Actions 步骤及 Job 后置钩子保留原临时目录。依赖缓存和签名缓存沿用原有位置。成功任务在产物上传后仅删除本轮所属的 checkout;失败任务保留该目录供检查。输入留空时使用原 Actions checkout。完整 Job 耗时须包含 checkout 创建和清理。 测量缓存时,`cache_namespace` 为两个依赖 store 和上游下载选择保留的子目录;留空使用正常共享缓存。新名称用于冷缓存采样,再次使用同名用于热缓存采样。名称由 1–64 个小写字母、数字或连字符组成,以字母或数字开头,且不能为 Windows 设备名。这不会重置独立的签名缓存或操作系统文件缓存;应将其状态、Job 开始至完成的总耗时与阶段耗时和产物体积一并报告。 diff --git a/apps/desktop/scripts/windows-build-workspace.d.mts b/apps/desktop/scripts/windows-build-workspace.d.mts index a9037d78c1..92f3a3c704 100644 --- a/apps/desktop/scripts/windows-build-workspace.d.mts +++ b/apps/desktop/scripts/windows-build-workspace.d.mts @@ -9,9 +9,9 @@ export interface WindowsBuildWorkspaceOptions { /** * Clone the clean selected commit into a new run-owned directory. * @param options - Original checkout and Actions run identity. - * @returns Checkout to use for packaging. + * @returns Checkout and temporary directory owned by this run. */ -export function prepareWindowsBuildWorkspace(options: WindowsBuildWorkspaceOptions): { directory: string } +export function prepareWindowsBuildWorkspace(options: WindowsBuildWorkspaceOptions): { directory: string; temporaryDirectory: string } /** * Remove the directory only when its ownership record matches the run. * @param options - Original checkout and Actions run identity. diff --git a/apps/desktop/scripts/windows-build-workspace.mjs b/apps/desktop/scripts/windows-build-workspace.mjs index a15ade90dd..c4048bf377 100644 --- a/apps/desktop/scripts/windows-build-workspace.mjs +++ b/apps/desktop/scripts/windows-build-workspace.mjs @@ -30,7 +30,7 @@ function locations(options) { } const owner = join(root, `windows-package-${runId}-${runAttempt}`) assertUnlinkedAncestors(owner) - return { root, owner, directory: join(owner, 'source') } + return { root, owner, directory: join(owner, 'source'), temporaryDirectory: join(owner, 'temp') } } function git(directory, args) { @@ -44,7 +44,7 @@ function ownership(options, paths) { /** * Clone the validated commit into a new run-owned checkout; failures retain diagnostics. * @param {{sourceDirectory: string, buildRoot: string, runId: string, runAttempt: string, sourceSha: string}} options - Source and run identity. - * @returns {{directory: string}} Checkout to use for packaging. + * @returns {{directory: string, temporaryDirectory: string}} Checkout and temporary directory owned by this run. */ export function prepareWindowsBuildWorkspace(options) { const paths = locations(options) @@ -54,12 +54,13 @@ export function prepareWindowsBuildWorkspace(options) { mkdirSync(paths.root, { recursive: true }) mkdirSync(paths.owner) writeFileSync(join(paths.owner, 'owner.json'), ownership(options, paths), { flag: 'wx' }) + mkdirSync(paths.temporaryDirectory) git(paths.owner, ['clone', '--no-hardlinks', '--no-checkout', resolve(options.sourceDirectory), paths.directory]) git(paths.directory, ['checkout', '--detach', options.sourceSha]) if (git(paths.directory, ['rev-parse', 'HEAD']) !== options.sourceSha || git(paths.directory, ['status', '--porcelain'])) { throw new Error('Isolated packaging checkout does not match the selected commit') } - return { directory: paths.directory } + return { directory: paths.directory, temporaryDirectory: paths.temporaryDirectory } } /** @@ -89,6 +90,6 @@ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1] else { if (process.argv.length !== 2 || !process.env.GITHUB_OUTPUT) throw new Error('Expected Actions output file and no arguments') const result = prepareWindowsBuildWorkspace(options) - appendFileSync(process.env.GITHUB_OUTPUT, `directory=${result.directory}\n`) + appendFileSync(process.env.GITHUB_OUTPUT, `directory=${result.directory}\ntemporaryDirectory=${result.temporaryDirectory}\n`) } } diff --git a/scripts/windows-build-workspace.spec.ts b/scripts/windows-build-workspace.spec.ts index f67ee577d5..aac6bf182f 100644 --- a/scripts/windows-build-workspace.spec.ts +++ b/scripts/windows-build-workspace.spec.ts @@ -40,7 +40,9 @@ it('clones the exact clean commit and removes only its run-owned checkout', () = stdio: 'pipe', }) const directory = join(options.buildRoot, 'windows-package-123-1', 'source') - expect(readFileSync(output, 'utf8')).toBe(`directory=${directory}\n`) + const temporaryDirectory = join(options.buildRoot, 'windows-package-123-1', 'temp') + expect(readFileSync(output, 'utf8')).toBe(`directory=${directory}\ntemporaryDirectory=${temporaryDirectory}\n`) + writeFileSync(join(temporaryDirectory, 'temporary.txt'), 'owned temporary data') expect(readFileSync(join(directory, 'source.txt'), 'utf8')).toBe('selected source') expect(existsSync(join(directory, 'private.env'))).toBe(false) expect(() => prepareWindowsBuildWorkspace(options)).toThrow() @@ -52,6 +54,7 @@ it('clones the exact clean commit and removes only its run-owned checkout', () = symlinkSync(external, join(directory, 'node_modules'), process.platform === 'win32' ? 'junction' : 'dir') removeWindowsBuildWorkspace(options) expect(existsSync(directory)).toBe(false) + expect(existsSync(temporaryDirectory)).toBe(false) expect(readFileSync(join(external, 'keep.txt'), 'utf8')).toBe('keep') expect(readFileSync(join(sourceDirectory, 'source.txt'), 'utf8')).toBe('selected source') const linked = join(root, 'linked') diff --git a/scripts/windows-package-workflow.spec.ts b/scripts/windows-package-workflow.spec.ts index bf2109c6b4..59d01fb894 100644 --- a/scripts/windows-package-workflow.spec.ts +++ b/scripts/windows-package-workflow.spec.ts @@ -160,6 +160,33 @@ it('routes all packaging consumers to the selected workspace and removes it only } }) +it('scopes the optional temporary directory to installer building without changing Actions or later steps', () => { + const build = job.steps.find(step => step.name === 'Build installer and verify packaged runtime')! + expect(build.env?.PACKAGE_BUILD_TEMP).toBe('${{ steps.build_workspace.outputs.temporaryDirectory }}') + expect(build.run).toContain([ + 'if ($env:PACKAGE_BUILD_TEMP) {', + ' $env:TEMP = $env:PACKAGE_BUILD_TEMP', + ' $env:TMP = $env:PACKAGE_BUILD_TEMP', + '}', + ].join('\n')) + expect(build.run!.indexOf('if ($env:PACKAGE_BUILD_TEMP)')).toBeLessThan(build.run!.indexOf('$packageArgs =')) + expect(build.run).not.toContain('GITHUB_ENV') + expect(build.run).not.toContain('RUNNER_TEMP') + expect(job.env).not.toHaveProperty('TEMP') + expect(job.env).not.toHaveProperty('TMP') + expect(job.env).not.toHaveProperty('PACKAGE_BUILD_TEMP') + for (const step of job.steps.filter(step => step !== build)) { + expect(step.env ?? {}).not.toHaveProperty('PACKAGE_BUILD_TEMP') + expect(step.env ?? {}).not.toHaveProperty('TEMP') + expect(step.env ?? {}).not.toHaveProperty('TMP') + expect(step.run ?? '').not.toMatch(/\$env:(?:TEMP|TMP)\s*=/u) + } + const selected = job.steps.find(step => step.id === 'build_workspace')! + const defaultBranch = selected.run!.split('} else {')[0]! + expect(defaultBranch).toContain('"directory=$env:GITHUB_WORKSPACE" >> $env:GITHUB_OUTPUT') + expect(defaultBranch).not.toContain('temporaryDirectory') +}) + it.skipIf(process.platform !== 'win32')('keeps the dependency store stable across run-specific pnpm homes', () => { const directory = mkdtempSync(resolve(tmpdir(), 'windows-package-store-')) try { From f511e17dbd99425ffcbfa4936efd27130b6e3810 Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:34:54 +0800 Subject: [PATCH 16/46] perf(windows): expose per-run installer compression --- .github/workflows/windows-package.yml | 6 ++++++ apps/desktop/packaging/README.i18n.yaml | 4 ++-- apps/desktop/packaging/README.md | 2 +- apps/desktop/packaging/README.zh.md | 2 +- .../scripts/desktop-package-environment.d.mts | 1 + .../scripts/desktop-package-environment.mjs | 8 ++++++++ apps/desktop/scripts/prepare-windows-ci.mjs | 2 ++ .../tests/desktop-package-environment.spec.ts | 19 +++++++++++++++++++ scripts/windows-package-workflow.spec.ts | 4 +++- 9 files changed, 43 insertions(+), 5 deletions(-) diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index 36dcc0a85d..1a75e1d91a 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -52,6 +52,11 @@ on: type: string default: '' required: false + compression_level: + description: '本轮安装器压缩等级:0..9;留空保留机器配置' + type: string + default: '' + required: false permissions: contents: read @@ -77,6 +82,7 @@ jobs: PACKAGE_SIGNING: ${{ inputs.signing }} PACKAGE_CACHE_NAMESPACE: ${{ inputs.cache_namespace }} PACKAGE_BUILD_ROOT: ${{ inputs.build_root }} + PACKAGE_WINDOWS_COMPRESSION_LEVEL: ${{ inputs.compression_level }} steps: - name: Reset packaging checkout working-directory: ${{ runner.temp }} diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index 3aea527fec..7592f49a87 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -45,8 +45,8 @@ en: b42f103358d0dfd7 zh: a534164ad9eb00da /desktop-packaging/runtime-file-selection-and-installation-checks: - en: 34273efbc5084038 - zh: f255e29623b6af76 + en: d87a53ab0f8b0db9 + zh: c3b2b96085f29d3b /desktop-packaging/desktop-runtime-preparation: en: bce2ee195a876985 zh: ae45df21c23e86c2 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index 211dac96e5..034cc8efd6 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -258,7 +258,7 @@ When extraction fails, the installer writes a report with the 7-Zip result and i Windows packaging fixes the 7-Zip filter to `BCJ` for compatibility with the bundled NSIS decoder. This preserves ARM64 binaries carried by dependencies in x64 installers; automatic ARM64 filtering produces entries that this decoder cannot extract. -`DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL` in the Windows dotenv or machine preset selects installer archive compression from `0` to `9`. If omitted, the existing `ELECTRON_BUILDER_COMPRESSION_LEVEL` environment override remains effective, otherwise the default is `9`. Lower levels trade installer size for packaging time; qualify the complete signed workflow and artifact size before changing a release preset. +`DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL` in the Windows dotenv or machine preset selects installer archive compression from `0` to `9`. If omitted, the existing `ELECTRON_BUILDER_COMPRESSION_LEVEL` environment override remains effective, otherwise the default is `9`. The manual Windows workflow accepts an optional `compression_level` from `0` to `9` that overrides the file setting for that run; leave it empty to retain the machine setting. Lower levels trade installer size for packaging time; qualify the complete signed workflow and artifact size before changing a release preset. NSIS removes its temporary extraction tree during installation, before the completion page or an automatic launch. The installed production packages remain ordinary files; startup does not extract them again. Installation still writes the complete application tree. diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index 154aac9ca4..d73b6b4725 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -258,7 +258,7 @@ Windows 安装器在启动时和选定目标目录后检查应用是否正在运 Windows 打包将 7-Zip 过滤器固定为 `BCJ`,以兼容内置的 NSIS 解码器。这样可以保留 x64 安装包中由依赖携带的 ARM64 二进制文件;自动 ARM64 过滤会生成该解码器无法解压的条目。 -Windows dotenv 或机器 preset 中的 `DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL` 选择 `0` 至 `9` 的安装包归档压缩等级。省略时,现有 `ELECTRON_BUILDER_COMPRESSION_LEVEL` 环境覆盖仍然生效,否则默认为 `9`。较低等级以安装包体积换取打包时间;修改发布 preset 前应验证完整签名 workflow 和产物体积。 +Windows dotenv 或机器 preset 中的 `DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL` 选择 `0` 至 `9` 的安装包归档压缩等级。省略时,现有 `ELECTRON_BUILDER_COMPRESSION_LEVEL` 环境覆盖仍然生效,否则默认为 `9`。手动 Windows workflow 接受可选的 `compression_level`,取值为 `0` 至 `9`,仅对本轮覆盖文件中的设置;留空则保留机器设置。较低等级以安装包体积换取打包时间;修改发布 preset 前应验证完整签名 workflow 和产物体积。 NSIS 在安装阶段清理临时解压目录,完成后才显示完成页或自动启动应用。已安装的生产依赖保持为普通文件;启动时不会再次解压。安装仍会写入完整的应用目录树。 diff --git a/apps/desktop/scripts/desktop-package-environment.d.mts b/apps/desktop/scripts/desktop-package-environment.d.mts index 995c117d07..4b9faf2481 100644 --- a/apps/desktop/scripts/desktop-package-environment.d.mts +++ b/apps/desktop/scripts/desktop-package-environment.d.mts @@ -10,6 +10,7 @@ export function localMacOSPackageEnvironment(environment?: NodeJS.ProcessEnv): N /** * Read a machine preset when selected, otherwise the target's required dotenv; no ambient release fallback. * PACKAGE_TEST_RELEASE_ID overrides the test preset only; blank retains its configured ID. + * PACKAGE_WINDOWS_COMPRESSION_LEVEL explicitly overrides Windows compression; blank retains the file setting. * @param platform Target platform. * @param environment Parent environment, retained only for unrelated build tools. * @param appRoot Desktop application directory; relative credential paths resolve here. diff --git a/apps/desktop/scripts/desktop-package-environment.mjs b/apps/desktop/scripts/desktop-package-environment.mjs index ff9ba89bde..22c65d1aa1 100644 --- a/apps/desktop/scripts/desktop-package-environment.mjs +++ b/apps/desktop/scripts/desktop-package-environment.mjs @@ -37,6 +37,7 @@ export function localMacOSPackageEnvironment(environment = process.env) { /** * Read a machine preset when selected, otherwise the target's required dotenv; no ambient release fallback. * PACKAGE_TEST_RELEASE_ID overrides the test preset only; blank retains its configured ID. + * PACKAGE_WINDOWS_COMPRESSION_LEVEL explicitly overrides Windows compression; blank retains the file setting. * @param {'win32' | 'darwin'} platform Target platform. * @param {NodeJS.ProcessEnv} environment Parent environment, retained only for unrelated build tools. * @param {string} appRoot Desktop application directory; relative credential paths resolve here. @@ -44,6 +45,12 @@ export function localMacOSPackageEnvironment(environment = process.env) { * @returns {NodeJS.ProcessEnv} Isolated environment with file-owned release settings. */ export function loadDesktopPackageEnvironment(platform, environment = process.env, appRoot = APP_ROOT, options = {}) { + const compression = environment.PACKAGE_WINDOWS_COMPRESSION_LEVEL + if (compression !== undefined && compression !== '') { + if (platform !== 'win32' || !/^[0-9]$/u.test(compression)) { + throw new Error('desktop package: PACKAGE_WINDOWS_COMPRESSION_LEVEL requires Windows and an integer from 0 to 9') + } + } const preset = environment.DSH_PACKAGING_PRESET let path = join(appRoot, platform === 'win32' ? '.env.windows' : '.env.macos') if (preset !== undefined) { @@ -69,6 +76,7 @@ export function loadDesktopPackageEnvironment(platform, environment = process.en : MACOS_SETTING.test(name) && !publicPlatformSetting.test(name))) } } + if (compression !== undefined && compression !== '') settings.DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL = compression const releaseId = environment.PACKAGE_TEST_RELEASE_ID?.trim() if (releaseId) { if (preset !== 'test') throw new Error('desktop package: test release ID override requires the test preset') diff --git a/apps/desktop/scripts/prepare-windows-ci.mjs b/apps/desktop/scripts/prepare-windows-ci.mjs index ac571095f4..b89227885a 100644 --- a/apps/desktop/scripts/prepare-windows-ci.mjs +++ b/apps/desktop/scripts/prepare-windows-ci.mjs @@ -5,6 +5,7 @@ import { execFileSync } from 'node:child_process' import { desktopCiVersion as windowsCiVersion } from './desktop-ci-version.mjs' export { desktopCiVersion as windowsCiVersion } from './desktop-ci-version.mjs' import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from './desktop-package-environment.mjs' +import { resolveWindowsPackageSettings } from './windows-package-settings.mjs' if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) { const appRoot = resolve(import.meta.dirname, '..') @@ -22,6 +23,7 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi } } validateDesktopPackageEnvironment(environment, { platform: 'win32', arch: 'x64' }, { unsigned: mode === 'unsigned' }) + console.log(`Windows installer compression level: ${resolveWindowsPackageSettings(environment).compressionLevel}`) if (!process.env.GITHUB_OUTPUT || !process.env.GITHUB_STEP_SUMMARY) throw new Error('Run this adapter from GitHub Actions') appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`) appendFileSync(process.env.GITHUB_STEP_SUMMARY, `Windows x64 ${mode} installer\n\nVersion: ${version}\n\nCommit: ${execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim()}\n\nChannel: ${process.env.DSH_DESKTOP_UPDATE_CHANNEL}\n\nSource: ${process.env.PACKAGE_SOURCE_REF}\n\nPreset: ${process.env.DSH_PACKAGING_PRESET}\n`) diff --git a/apps/desktop/tests/desktop-package-environment.spec.ts b/apps/desktop/tests/desktop-package-environment.spec.ts index 9a359d3529..9390af557d 100644 --- a/apps/desktop/tests/desktop-package-environment.spec.ts +++ b/apps/desktop/tests/desktop-package-environment.spec.ts @@ -32,6 +32,25 @@ describe('Desktop local packaging configuration', () => { }) }) + it.each(['0', '3', '9'])('applies an explicit Windows workflow compression override %s', async (value) => { + await withDirectory(async (directory) => { + await writeFile(join(directory, '.env.windows'), 'DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=5\n') + const selected = loadDesktopPackageEnvironment('win32', { PACKAGE_WINDOWS_COMPRESSION_LEVEL: value }, directory) + expect(resolveWindowsPackageSettings(selected).compressionLevel).toBe(Number(value)) + const unchanged = loadDesktopPackageEnvironment('win32', { PACKAGE_WINDOWS_COMPRESSION_LEVEL: '' }, directory) + expect(resolveWindowsPackageSettings(unchanged).compressionLevel).toBe(5) + }) + }) + + it.each([' ', '-1', '10', '3.5', '03', 'fast'])('rejects invalid workflow compression %j before reading settings', (value) => { + expect(() => loadDesktopPackageEnvironment('win32', { PACKAGE_WINDOWS_COMPRESSION_LEVEL: value }, 'missing-settings')) + .toThrow('PACKAGE_WINDOWS_COMPRESSION_LEVEL') + }) + + it('rejects a Windows compression override for macOS before reading settings', () => { + expect(() => loadDesktopPackageEnvironment('darwin', { PACKAGE_WINDOWS_COMPRESSION_LEVEL: '3' }, 'missing-settings')) + .toThrow('PACKAGE_WINDOWS_COMPRESSION_LEVEL') + }) it.each(['', '-1', '10', '3.5', '03', 'fast'])('rejects invalid compression %j before preparation', (value) => { const selected = { ...RELEASE, DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL: value } expect(() => { validateDesktopPackageEnvironment(selected, WINDOWS, { unsigned: true }) }) diff --git a/scripts/windows-package-workflow.spec.ts b/scripts/windows-package-workflow.spec.ts index 59d01fb894..48661ac935 100644 --- a/scripts/windows-package-workflow.spec.ts +++ b/scripts/windows-package-workflow.spec.ts @@ -68,9 +68,11 @@ it('routes manual packaging to the dedicated runner with explicit preset and sig expect(workflow.concurrency).toEqual({ group: 'windows-desktop-package', 'cancel-in-progress': false }) expect(workflow.permissions).toEqual({ contents: 'read' }) expect(Object.keys(workflow.on.workflow_dispatch!.inputs).sort()).toEqual([ - 'build_root', 'build_version', 'cache_namespace', 'preset', 'signing', 'source_ref', 'test_release_id', 'update_channel', 'version_mode', + 'build_root', 'build_version', 'cache_namespace', 'compression_level', 'preset', 'signing', 'source_ref', 'test_release_id', 'update_channel', 'version_mode', ]) expect(workflow.on.workflow_dispatch!.inputs.signing!.default).toBe('signed') + expect(workflow.on.workflow_dispatch!.inputs.compression_level).toMatchObject({ type: 'string', default: '', required: false }) + expect(job.env.PACKAGE_WINDOWS_COMPRESSION_LEVEL).toBe('${{ inputs.compression_level }}') expect(workflow.on.workflow_dispatch!.inputs.build_version!.required).toBe(false) expect(workflow.on.workflow_dispatch!.inputs.version_mode!.default).toBe('default') expect(workflow.on.workflow_dispatch!.inputs.source_ref!.default).toBe('master') From 10c3dd5a3e5698017bd7a1f2c15e580bb6e7137e Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:32:28 +0800 Subject: [PATCH 17/46] chore(windows): record dependency install resources --- .github/workflows/windows-package.yml | 2 ++ apps/desktop/packaging/README.i18n.yaml | 4 ++-- apps/desktop/packaging/README.md | 2 +- apps/desktop/packaging/README.zh.md | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index 1a75e1d91a..4e5ef76be6 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -154,6 +154,8 @@ jobs: - name: Install locked dependencies working-directory: ${{ steps.build_workspace.outputs.directory }} run: | + node -e "const os=require('node:os'); console.log(JSON.stringify({node:process.version,availableParallelism:os.availableParallelism(),logicalCpus:os.cpus().length,totalMemoryBytes:os.totalmem(),freeMemoryBytes:os.freemem()}))" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } pnpm store path if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } pnpm install --frozen-lockfile diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index 7592f49a87..fd125fe208 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -24,8 +24,8 @@ en: 5b4f0f80a117f202 zh: ff05be196c181087 /desktop-packaging/windows-actions-packaging: - en: faf2b4cffb576757 - zh: b9c807eea360d53d + en: 330c905d2b610fc7 + zh: 1985b588241c4f95 /desktop-packaging/macos-self-hosted-packaging: en: a3af9523fdc60036 zh: 5e18d4740e9b7f57 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index 034cc8efd6..9be5fd93ef 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -115,7 +115,7 @@ Open the completed run in Actions and download its Artifacts. `windows-x64-signe For path-sensitive Windows build measurements, `build_root` optionally selects an absolute directory outside existing Git checkouts. Each run clones the validated source commit into a new `windows-package--/source` directory; ignored files and previous build outputs are not copied. Only the packaging process uses a sibling run-owned `temp` directory through `TEMP` and `TMP`; Actions steps and post-job hooks retain their original temporary directories. Dependency and signing caches retain their existing locations. Successful runs remove only their owned checkout after artifact uploads; failed runs retain it for inspection. Leave the input empty to build in the original Actions checkout. Include checkout creation and cleanup in the complete Job timing. -For cache measurements, `cache_namespace` selects a retained subdirectory for both dependency stores and upstream downloads; leave it empty for normal shared caches. Use a new name for a cold sample and repeat that name for warm samples. Names contain 1–64 lowercase letters, digits or hyphens, start with a letter or digit, and cannot be Windows device names. This does not reset the separate signature cache or OS file cache; report those states and Job start-to-completion time alongside phase timings and artifact size. +For cache measurements, `cache_namespace` selects a retained subdirectory for both dependency stores and upstream downloads; leave it empty for normal shared caches. Use a new name for a cold sample and repeat that name for warm samples. Names contain 1–64 lowercase letters, digits or hyphens, start with a letter or digit, and cannot be Windows device names. This does not reset the separate signature cache or OS file cache; report those states and Job start-to-completion time alongside phase timings and artifact size. Before dependency installation, the workflow records the Node version, available CPU parallelism, logical CPU count, and total/free memory. diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index d73b6b4725..8eb36a6233 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -115,7 +115,7 @@ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-s 测量 Windows 构建路径对性能的影响时,可用 `build_root` 指定现有 Git checkout 之外的绝对目录。每轮任务将已验证的源码提交克隆到新的 `windows-package--/source` 目录;不会复制忽略文件或此前的构建输出。仅打包进程通过 `TEMP` 和 `TMP` 使用同轮所属的同级 `temp` 目录;Actions 步骤及 Job 后置钩子保留原临时目录。依赖缓存和签名缓存沿用原有位置。成功任务在产物上传后仅删除本轮所属的 checkout;失败任务保留该目录供检查。输入留空时使用原 Actions checkout。完整 Job 耗时须包含 checkout 创建和清理。 -测量缓存时,`cache_namespace` 为两个依赖 store 和上游下载选择保留的子目录;留空使用正常共享缓存。新名称用于冷缓存采样,再次使用同名用于热缓存采样。名称由 1–64 个小写字母、数字或连字符组成,以字母或数字开头,且不能为 Windows 设备名。这不会重置独立的签名缓存或操作系统文件缓存;应将其状态、Job 开始至完成的总耗时与阶段耗时和产物体积一并报告。 +测量缓存时,`cache_namespace` 为两个依赖 store 和上游下载选择保留的子目录;留空使用正常共享缓存。新名称用于冷缓存采样,再次使用同名用于热缓存采样。名称由 1–64 个小写字母、数字或连字符组成,以字母或数字开头,且不能为 Windows 设备名。这不会重置独立的签名缓存或操作系统文件缓存;应将其状态、Job 开始至完成的总耗时与阶段耗时和产物体积一并报告。workflow 在依赖安装前记录 Node 版本、可用 CPU 并行数、逻辑 CPU 数及总内存和空闲内存。 From 9c622fad9602271cfe046b423d419f59b0f88f6b Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:54:17 +0800 Subject: [PATCH 18/46] fix(windows): run packaging tasks at normal scheduling priority --- apps/desktop/scripts/windows-runner-task.ps1 | 2 +- apps/desktop/tests/windows-runner-task.ps1 | 8 ++++++++ apps/desktop/windows-signing/README.i18n.yaml | 4 ++-- apps/desktop/windows-signing/README.md | 2 +- apps/desktop/windows-signing/README.zh.md | 2 +- 5 files changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/desktop/scripts/windows-runner-task.ps1 b/apps/desktop/scripts/windows-runner-task.ps1 index 47efba43e0..5a994f6671 100644 --- a/apps/desktop/scripts/windows-runner-task.ps1 +++ b/apps/desktop/scripts/windows-runner-task.ps1 @@ -52,7 +52,7 @@ if ($Action -eq 'install') { (New-ScheduledTaskTrigger -AtLogOn -User $sid), (New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes($RestartMinutes) -RepetitionInterval (New-TimeSpan -Minutes $RestartMinutes)) ) - $settings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -ExecutionTimeLimit ([TimeSpan]::Zero) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries + $settings = New-ScheduledTaskSettingsSet -Priority 4 -MultipleInstances IgnoreNew -ExecutionTimeLimit ([TimeSpan]::Zero) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries $settings.Enabled = $false $null = Register-ScheduledTask -TaskName $taskName -TaskPath '\' -Action $execution -Principal $principal -Trigger $trigger -Settings $settings -Description "DSH packaging runner: $RunnerRoot; owner: $sid" Write-Output "Installed disabled task $taskName. Run start while logged in; reboot requires desktop login. Logs: $serviceDir" diff --git a/apps/desktop/tests/windows-runner-task.ps1 b/apps/desktop/tests/windows-runner-task.ps1 index da9a8ff8ac..f72e796491 100644 --- a/apps/desktop/tests/windows-runner-task.ps1 +++ b/apps/desktop/tests/windows-runner-task.ps1 @@ -14,6 +14,11 @@ param([string]$RunnerRoot, [string]$ConfigRoot, [switch]$CheckOnly) if ($CheckOnly) { exit 0 } Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class ConsoleProbe { [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow(); }' Add-Content -LiteralPath (Join-Path $RunnerRoot 'consoles.txt') -Value ([ConsoleProbe]::GetConsoleWindow().ToInt64()) +Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class SchedulingProbe { [DllImport("ntdll.dll")] static extern int NtQueryInformationProcess(IntPtr handle, int kind, out int value, int size, out int length); public static int Read(IntPtr handle, int kind) { int value, length; int status = NtQueryInformationProcess(handle, kind, out value, 4, out length); if (status != 0) throw new InvalidOperationException("Priority query failed: " + status); return value; } }' +$process = [Diagnostics.Process]::GetCurrentProcess() +try { + @{ Cpu = $process.PriorityClass.ToString(); Io = [SchedulingProbe]::Read($process.Handle, 33); Memory = [SchedulingProbe]::Read($process.Handle, 39) } | ConvertTo-Json -Compress | Add-Content -LiteralPath (Join-Path $RunnerRoot 'priorities.jsonl') +} finally { $process.Dispose() } Add-Content -LiteralPath (Join-Path $RunnerRoot 'starts.txt') -Value $PID if (-not (Test-Path -LiteralPath (Join-Path $RunnerRoot 'failed-once'))) { Set-Content -LiteralPath (Join-Path $RunnerRoot 'failed-once') -Value 'yes' @@ -50,6 +55,7 @@ try { $peOffset = [BitConverter]::ToInt32($image, 0x3c) if ([BitConverter]::ToUInt16($image, $peOffset + 24 + 68) -ne 2) { throw 'Launcher must use the Windows GUI subsystem.' } if ($task.State -ne 'Disabled' -or $task.Settings.ExecutionTimeLimit -ne 'PT0S' -or $task.Settings.MultipleInstances -ne 'IgnoreNew') { throw 'Unexpected task scheduling settings.' } + if ($task.Settings.Priority -ne 4) { throw 'Runner task must use normal CPU, I/O and memory priority.' } Invoke-Manager install -ExpectFailure $runScript = Join-Path $runner 'dsh-task/run.ps1' $originalRun = Get-Content -LiteralPath $runScript -Raw @@ -75,6 +81,8 @@ if (-not (Test-Path -LiteralPath (Join-Path $PSScriptRoot 'inherited-writer-star if ([int](Get-Content -LiteralPath (Join-Path $runner 'dsh-task/attempts.txt') -Raw) -ne 2) { throw 'Drain recovery did not consume the normal restart budget.' } $consoles = @(Get-Content -LiteralPath (Join-Path $runner 'consoles.txt')) if ($consoles.Count -ne 2 -or @($consoles | Where-Object { $_ -ne '0' }).Count) { throw 'Initial or recovered runner allocated a console.' } + $priorities = @(Get-Content -LiteralPath (Join-Path $runner 'priorities.jsonl') | ForEach-Object { $_ | ConvertFrom-Json }) + if ($priorities.Count -ne 2 -or @($priorities | Where-Object { $_.Cpu -ne 'Normal' -or $_.Io -ne 2 -or $_.Memory -ne 5 }).Count) { throw 'Initial or recovered runner inherited background scheduling priority.' } $childId = [int]@(Get-Content -LiteralPath $starts)[-1] $child = Get-Process -Id $childId Invoke-Manager start diff --git a/apps/desktop/windows-signing/README.i18n.yaml b/apps/desktop/windows-signing/README.i18n.yaml index 84902b410f..7a8c03034f 100644 --- a/apps/desktop/windows-signing/README.i18n.yaml +++ b/apps/desktop/windows-signing/README.i18n.yaml @@ -21,8 +21,8 @@ en: bb889e9514678d1c zh: 48cbec1e907d6a2b /windows-signing-and-runner-operations/machine-setup-and-runner-startup/supervised-startup: - en: d8e2ed7bde76ed7e - zh: 475917ecbd158b48 + en: 3e8ce94b8383163d + zh: a545c6c0e86b75a1 /windows-signing-and-runner-operations/signing-configuration: en: 4b5548818375eb9a zh: 8c975adbb1dca9c8 diff --git a/apps/desktop/windows-signing/README.md b/apps/desktop/windows-signing/README.md index 122bb7d705..10fb15194f 100644 --- a/apps/desktop/windows-signing/README.md +++ b/apps/desktop/windows-signing/README.md @@ -38,7 +38,7 @@ Run `pwsh -NoProfile -File apps/desktop/scripts/setup-windows-packaging.ps1 -Roo For the complete registration, label, routing and approval sequence, start with [CI publication setup](../publishing/ci/README.md#setup-sequence). The task installer requires the Windows .NET Framework 4.5+ compiler at `%SystemRoot%/Microsoft.NET/Framework64/v4.0.30319/csc.exe`. It compiles a GUI executable into `runner/dsh-task/launcher.exe`; this executable starts PowerShell with no console and waits for it to exit. The output and errors of `run.ps1` itself go to uniquely named `launcher-*.out.log` and `launcher-*.err.log`; filenames include UTC time, PID and a random identifier. Launcher failures disable periodic starts, including failures before the retry budget can be read. Task result `2` indicates launcher failure; `3` means its error log could not be written; `4` means disabling the task also failed and an operator must disable it in Task Scheduler. After the direct child exits, output draining is limited to five seconds. A timeout returns `5` with a `launcher-*.drain.log` diagnostic: output may be incomplete, but periodic recovery continues within the existing restart budget. Descendant processes may outlive the exited runner; draining logs does not terminate them. The recorded stop target is the launcher process, so stop includes the launcher and its descendants. Normal runner output remains in `run-*.log`. -Run `pwsh -NoProfile -File apps/desktop/scripts/windows-runner-task.ps1 -Action install -RunnerRoot -ConfigRoot /packaging` as the runner's Windows user, using its installed PowerShell 7. The installer checks the environment and copies startup scripts into `runner/dsh-task`; registration, tools and secrets remain separate. It registers a disabled, limited-permission interactive Task Scheduler task without storing an account password. For `-Action start` or `-Action status`, pass only `-RunnerRoot`; configuration and retry options apply only to installation. The task starts after this user's desktop login, including after reboot, and stops on logout. It does not provide unattended boot or unlock a signing Token. +Run `pwsh -NoProfile -File apps/desktop/scripts/windows-runner-task.ps1 -Action install -RunnerRoot -ConfigRoot /packaging` as the runner's Windows user, using its installed PowerShell 7. The installer checks the environment and copies startup scripts into `runner/dsh-task`; registration, tools and secrets remain separate. It registers a disabled, limited-permission interactive Task Scheduler task without storing an account password. Task priority 4 gives the runner and its children normal CPU, I/O and memory priority; the scheduler default 7 lowers all three and slows dependency installation. Existing tasks retain their settings: while the runner is idle, stop, uninstall and reinstall the task before starting it again. For `-Action start` or `-Action status`, pass only `-RunnerRoot`; configuration and retry options apply only to installation. The task starts after this user's desktop login, including after reboot, and stops on logout. It does not provide unattended boot or unlock a signing Token. The task permits one instance and has no execution time limit. A repeating trigger checks every `-RestartMinutes` minutes (default `1`) and restarts an exited launcher, including an unexpected zero exit. The persisted budget permits the initial launch plus `-RestartCount` restarts (default `10`) between explicit starts. Every desktop login also consumes this budget; `status` shows the remaining launches. After the budget is exhausted, login alone cannot restart the runner. Starting an already running task does not reset the budget. Missing or invalid budget/configuration files disable automatic starts until repaired. GitHub's launcher owns runner updates; task recovery does not rerun a failed workflow or clear signing interlocks. After retries are exhausted, inspect `runner/dsh-task/run-*.log`, the runner's `_diag` logs and `status`, correct the cause, then run `start`. A running task alone does not prove connectivity: verify `Connected to GitHub` and `Listening for Jobs`, then verify assignment in Actions. Both `run-*.log` and `launcher-*.log` are retained for operator-managed cleanup. diff --git a/apps/desktop/windows-signing/README.zh.md b/apps/desktop/windows-signing/README.zh.md index 243bb0467f..df756d7d1e 100644 --- a/apps/desktop/windows-signing/README.zh.md +++ b/apps/desktop/windows-signing/README.zh.md @@ -38,7 +38,7 @@ description: "配置和维护 Windows 打包 runner、签名凭据、EV Token 完整的注册、标签、路由和审批配置顺序见 [CI 发布配置](../publishing/ci/README.zh.md#setup-sequence)。任务安装器需要 Windows .NET Framework 4.5+ 编译器,路径为 `%SystemRoot%/Microsoft.NET/Framework64/v4.0.30319/csc.exe`。它将 GUI 可执行文件编译到 `runner/dsh-task/launcher.exe`;该程序不分配控制台地启动 PowerShell,并等待其退出。`run.ps1` 自身的输出和错误分别写入名称唯一的 `launcher-*.out.log` 和 `launcher-*.err.log`,文件名包含 UTC 时间、PID 和随机标识。启动器失败时禁用周期启动,包括尚未读取重试预算时发生的失败。任务结果 `2` 表示启动器失败,`3` 表示无法写入错误日志,`4` 表示禁用任务也失败,需要操作者在计划任务中手动禁用。直接子进程退出后,最多等待五秒排空输出;超时返回 `5`,将诊断写入 `launcher-*.drain.log`:输出可能不完整,但周期恢复仍受原有重试预算约束。后代进程可能在 runner 退出后继续运行;日志排空不会终止它们。记录的停止目标是启动器进程,因此 stop 覆盖启动器及其后代。正常 runner 输出仍保存在 `run-*.log`。 -以 runner 的 Windows 用户运行 `pwsh -NoProfile -File apps/desktop/scripts/windows-runner-task.ps1 -Action install -RunnerRoot -ConfigRoot /packaging`,使用该机器安装的 PowerShell 7。安装器检查环境,并将启动脚本复制到 `runner/dsh-task`;注册、工具和凭据仍单独管理。它注册一个初始禁用、权限受限的交互式计划任务,不保存账户密码。执行 `-Action start` 或 `-Action status` 时只传 `-RunnerRoot`;配置目录及重试选项仅适用于安装。任务在该用户登录桌面后启动,重启后也需要登录,注销时停止。它不提供无人值守开机,也不解锁签名 Token。 +以 runner 的 Windows 用户运行 `pwsh -NoProfile -File apps/desktop/scripts/windows-runner-task.ps1 -Action install -RunnerRoot -ConfigRoot /packaging`,使用该机器安装的 PowerShell 7。安装器检查环境,并将启动脚本复制到 `runner/dsh-task`;注册、工具和凭据仍单独管理。它注册一个初始禁用、权限受限的交互式计划任务,不保存账户密码。任务优先级 4 让 runner 及子进程使用普通 CPU、I/O 和内存优先级;计划任务默认值 7 会降低三者的优先级,使依赖安装变慢。已有任务保留原设置:在 runner 空闲时停止、卸载并重新安装任务,再启动。执行 `-Action start` 或 `-Action status` 时只传 `-RunnerRoot`;配置目录及重试选项仅适用于安装。任务在该用户登录桌面后启动,重启后也需要登录,注销时停止。它不提供无人值守开机,也不解锁签名 Token。 任务只允许一个实例,且没有执行时限。周期触发器每隔 `-RestartMinutes` 分钟(默认 `1`)检查一次,并恢复已退出的启动器,包括意外的零退出码。持久预算允许首次启动及 `-RestartCount` 次重启(默认 `10`),直到再次显式启动。每次桌面登录也会消耗预算,`status` 显示剩余启动次数;预算耗尽后,仅登录不能重启 runner。对正在运行的任务执行 start 不会重置预算。预算或配置文件缺失、无效时,自动启动会禁用,需修复后恢复。GitHub 启动器负责 runner 更新;任务恢复不会重跑失败的 workflow,也不会清除签名互锁。重试耗尽后,检查 `runner/dsh-task/run-*.log`、runner 的 `_diag` 日志和 `status`,修复原因后运行 `start`。任务运行不等于连接成功:确认出现 `Connected to GitHub` 和 `Listening for Jobs`,再到 Actions 确认任务分配。`run-*.log` 和 `launcher-*.log` 均保留,由操作者管理清理。 From b6e387e5b3a7fe0bb90375b723e0af1f033e19c2 Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:38:30 +0800 Subject: [PATCH 19/46] fix(windows): normalize packaging step scheduling priority --- .github/workflows/ci.yml | 3 + .github/workflows/windows-package.yml | 3 + apps/desktop/packaging/README.i18n.yaml | 4 +- apps/desktop/packaging/README.md | 2 + apps/desktop/packaging/README.zh.md | 2 + .../scripts/windows-packaging-priority.ps1 | 55 +++++++++++++++++++ .../tests/windows-packaging-priority.ps1 | 54 ++++++++++++++++++ scripts/windows-package-workflow.spec.ts | 10 ++++ 8 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/scripts/windows-packaging-priority.ps1 create mode 100644 apps/desktop/tests/windows-packaging-priority.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41f28c8e7e..96f5219a15 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -579,6 +579,9 @@ jobs: - name: Verify windowless Windows runner launcher shell: pwsh run: ./apps/desktop/tests/windows-runner-launcher.ps1 + - name: Verify packaging process priority inheritance + shell: pwsh + run: ./apps/desktop/tests/windows-packaging-priority.ps1 - name: Run Windows observational gates on built artifacts id: observational continue-on-error: true diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index 4e5ef76be6..4142d30285 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -118,6 +118,7 @@ jobs: const branch=cp.execFileSync('git',['rev-parse','--abbrev-ref','HEAD'],{encoding:'utf8'}).trim(); if(branch==='HEAD' || branch!==ref || ref.startsWith('refs/')) throw Error('Select a branch in this repository, not a SHA or PR ref'); } + if(!fs.existsSync('apps/desktop/scripts/windows-packaging-priority.ps1')) throw Error('Source lacks Windows packaging priority support; select a newer release'); if(!fs.readFileSync('apps/desktop/scripts/desktop-ci-version.mjs','utf8').includes('PACKAGE_VERSION_MODE')) throw Error('Source lacks Desktop channel/version CI support; select a newer release');" - uses: pnpm/action-setup@v5.0.0 with: @@ -154,6 +155,7 @@ jobs: - name: Install locked dependencies working-directory: ${{ steps.build_workspace.outputs.directory }} run: | + & ./apps/desktop/scripts/windows-packaging-priority.ps1 node -e "const os=require('node:os'); console.log(JSON.stringify({node:process.version,availableParallelism:os.availableParallelism(),logicalCpus:os.cpus().length,totalMemoryBytes:os.totalmem(),freeMemoryBytes:os.freemem()}))" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } pnpm store path @@ -181,6 +183,7 @@ jobs: PACKAGE_VERSION: ${{ steps.settings.outputs.version }} PACKAGE_BUILD_TEMP: ${{ steps.build_workspace.outputs.temporaryDirectory }} run: | + & ./apps/desktop/scripts/windows-packaging-priority.ps1 if ($env:PACKAGE_BUILD_TEMP) { $env:TEMP = $env:PACKAGE_BUILD_TEMP $env:TMP = $env:PACKAGE_BUILD_TEMP diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index fd125fe208..766a395821 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -24,8 +24,8 @@ en: 5b4f0f80a117f202 zh: ff05be196c181087 /desktop-packaging/windows-actions-packaging: - en: 330c905d2b610fc7 - zh: 1985b588241c4f95 + en: e628d75c47317979 + zh: 17055233501f4df6 /desktop-packaging/macos-self-hosted-packaging: en: a3af9523fdc60036 zh: 5e18d4740e9b7f57 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index 9be5fd93ef..92eb557309 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -117,6 +117,8 @@ For path-sensitive Windows build measurements, `build_root` optionally selects a For cache measurements, `cache_namespace` selects a retained subdirectory for both dependency stores and upstream downloads; leave it empty for normal shared caches. Use a new name for a cold sample and repeat that name for warm samples. Names contain 1–64 lowercase letters, digits or hyphens, start with a letter or digit, and cannot be Windows device names. This does not reset the separate signature cache or OS file cache; report those states and Job start-to-completion time alongside phase timings and artifact size. Before dependency installation, the workflow records the Node version, available CPU parallelism, logical CPU count, and total/free memory. +The dependency-install and packaging steps normalize their own shell to normal CPU, I/O and memory priority before launching children, and record all three values before and after adjustment. Failure to establish these values stops the step. This does not modify the runner task, account, startup triggers or other processes. + ## macOS self-hosted packaging diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index 8eb36a6233..c5d02a9073 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -117,6 +117,8 @@ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-s 测量缓存时,`cache_namespace` 为两个依赖 store 和上游下载选择保留的子目录;留空使用正常共享缓存。新名称用于冷缓存采样,再次使用同名用于热缓存采样。名称由 1–64 个小写字母、数字或连字符组成,以字母或数字开头,且不能为 Windows 设备名。这不会重置独立的签名缓存或操作系统文件缓存;应将其状态、Job 开始至完成的总耗时与阶段耗时和产物体积一并报告。workflow 在依赖安装前记录 Node 版本、可用 CPU 并行数、逻辑 CPU 数及总内存和空闲内存。 +依赖安装和打包步骤在启动子进程前,将自身 shell 的 CPU、I/O 和内存优先级调整为普通值,并记录调整前后的三项值;无法确认调整成功时停止该步骤。这不会修改 runner 任务、账户、启动触发器或其他进程。 + ## macOS 自托管打包 diff --git a/apps/desktop/scripts/windows-packaging-priority.ps1 b/apps/desktop/scripts/windows-packaging-priority.ps1 new file mode 100644 index 0000000000..ed4ae4a49e --- /dev/null +++ b/apps/desktop/scripts/windows-packaging-priority.ps1 @@ -0,0 +1,55 @@ +# Normalize only this workflow shell and the children it subsequently launches. +[CmdletBinding()] +param([switch]$InspectOnly) + +$ErrorActionPreference = 'Stop' +if (-not $IsWindows) { throw 'Packaging process priority requires Windows.' } + +if (-not ('DshPackagingPriority' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +public static class DshPackagingPriority { + [DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess(); + [DllImport("ntdll.dll")] static extern int NtSetInformationProcess(IntPtr process, int kind, ref int value, int size); + [DllImport("ntdll.dll")] static extern int NtQueryInformationProcess(IntPtr process, int kind, out int value, int size, out int length); + public static int Read(int kind) { + int value, length; + int status = NtQueryInformationProcess(GetCurrentProcess(), kind, out value, 4, out length); + if (status != 0 || length != 4) throw new InvalidOperationException("Cannot read packaging priority: " + status); + return value; + } + public static void Set(int kind, int value) { + int status = NtSetInformationProcess(GetCurrentProcess(), kind, ref value, 4); + if (status != 0) throw new InvalidOperationException("Cannot set packaging priority: " + status); + } +} +'@ +} + +$process = [Diagnostics.Process]::GetCurrentProcess() +try { + $before = [ordered]@{ + cpu = $process.PriorityClass.ToString() + io = [DshPackagingPriority]::Read(33) + memory = [DshPackagingPriority]::Read(39) + } + if (-not $InspectOnly) { + $process.PriorityClass = [Diagnostics.ProcessPriorityClass]::Normal + # PROCESSINFOCLASS: ProcessIoPriority (33), ProcessPagePriority (39). + [DshPackagingPriority]::Set(33, 2) + [DshPackagingPriority]::Set(39, 5) + } + $process.Refresh() + $after = [ordered]@{ + cpu = $process.PriorityClass.ToString() + io = [DshPackagingPriority]::Read(33) + memory = [DshPackagingPriority]::Read(39) + } + [ordered]@{ event = 'packaging-process-priority'; before = $before; after = $after } | ConvertTo-Json -Compress + if (-not $InspectOnly -and ($after.cpu -ne 'Normal' -or $after.io -ne 2 -or $after.memory -ne 5)) { + throw 'Packaging process did not reach normal CPU, I/O and memory priority.' + } +} finally { + $process.Dispose() +} diff --git a/apps/desktop/tests/windows-packaging-priority.ps1 b/apps/desktop/tests/windows-packaging-priority.ps1 new file mode 100644 index 0000000000..7a0e827acb --- /dev/null +++ b/apps/desktop/tests/windows-packaging-priority.ps1 @@ -0,0 +1,54 @@ +# Exercise priority inheritance in disposable children without changing the test host or runner. +[CmdletBinding()] +param([switch]$Child) +$ErrorActionPreference = 'Stop' +if (-not $IsWindows) { throw 'Requires PowerShell 7 on Windows.' } +$helper = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../scripts/windows-packaging-priority.ps1')) +if ($Child) { + $null = & $helper -InspectOnly + $self = [Diagnostics.Process]::GetCurrentProcess() + try { + $self.PriorityClass = [Diagnostics.ProcessPriorityClass]::BelowNormal + [DshPackagingPriority]::Set(33, 1) + [DshPackagingPriority]::Set(39, 2) + $report = (& $helper | ConvertFrom-Json) + if ($report.before.cpu -ne 'BelowNormal' -or $report.before.io -ne 1 -or $report.before.memory -ne 2) { + throw 'Fixture did not establish background priority.' + } + if ($report.after.cpu -ne 'Normal' -or $report.after.io -ne 2 -or $report.after.memory -ne 5) { + throw 'Packaging shell retained background priority.' + } + $inherited = (& (Join-Path $PSHOME 'pwsh.exe') -NoProfile -File $helper -InspectOnly | ConvertFrom-Json) + if ($LASTEXITCODE -ne 0) { throw 'Priority inspection child failed.' } + if ($inherited.before.cpu -ne 'Normal' -or $inherited.before.io -ne 2 -or $inherited.before.memory -ne 5) { + throw 'Packaging child did not inherit normal priority.' + } + } finally { + $self.Dispose() + } + exit 0 +} +$process = [Diagnostics.Process]::new() +$process.StartInfo.FileName = Join-Path $PSHOME 'pwsh.exe' +$process.StartInfo.UseShellExecute = $false +$process.StartInfo.CreateNoWindow = $true +$process.StartInfo.RedirectStandardError = $true +foreach ($argument in @('-NoProfile', '-File', $PSCommandPath, '-Child')) { + $process.StartInfo.ArgumentList.Add($argument) +} +$started = $false +try { + $started = $process.Start() + if (-not $started) { throw 'Could not start priority test.' } + $errorOutput = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit(30000)) { throw 'Priority test exceeded 30 seconds.' } + if (-not $errorOutput.Wait(5000)) { throw 'Priority test error stream did not close.' } + if ($process.ExitCode -ne 0) { throw "Priority test failed: $($process.ExitCode): $($errorOutput.Result)" } +} finally { + if ($started -and -not $process.HasExited) { + $process.Kill($true) + $process.WaitForExit() + } + $process.Dispose() +} +Write-Output 'Packaging priority normalization and child inheritance passed.' diff --git a/scripts/windows-package-workflow.spec.ts b/scripts/windows-package-workflow.spec.ts index 48661ac935..1b3efd3ba6 100644 --- a/scripts/windows-package-workflow.spec.ts +++ b/scripts/windows-package-workflow.spec.ts @@ -162,6 +162,16 @@ it('routes all packaging consumers to the selected workspace and removes it only } }) +it('normalizes priority in the dependency and packaging shells before launching their children', () => { + const command = '& ./apps/desktop/scripts/windows-packaging-priority.ps1' + expect(job.steps.find(entry => entry.name === 'Validate selected source and adapter support')!.run) + .toContain("fs.existsSync('apps/desktop/scripts/windows-packaging-priority.ps1')") + for (const name of ['Install locked dependencies', 'Build installer and verify packaged runtime']) { + const step = job.steps.find(entry => entry.name === name)! + expect(step.run!.trimStart().startsWith(command + '\n')).toBe(true) + } +}) + it('scopes the optional temporary directory to installer building without changing Actions or later steps', () => { const build = job.steps.find(step => step.name === 'Build installer and verify packaged runtime')! expect(build.env?.PACKAGE_BUILD_TEMP).toBe('${{ steps.build_workspace.outputs.temporaryDirectory }}') From eda31bfc29c31e1bb2cb1c8321327c9b426a8dfe Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:35:40 +0800 Subject: [PATCH 20/46] perf(windows): store installer uploads without recompression --- .github/workflows/windows-package.yml | 2 ++ apps/desktop/packaging/README.i18n.yaml | 4 ++-- apps/desktop/packaging/README.md | 2 ++ apps/desktop/packaging/README.zh.md | 2 ++ scripts/windows-package-workflow.spec.ts | 2 +- 5 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index 4142d30285..4102546618 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -213,6 +213,7 @@ jobs: uses: actions/upload-artifact@v7.0.1 with: name: windows-x64-unsigned-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }} + compression-level: 0 include-hidden-files: true path: ${{ steps.retained.outputs.directory }}/* if-no-files-found: error @@ -222,6 +223,7 @@ jobs: uses: actions/upload-artifact@v7.0.1 with: name: windows-x64-signed-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }} + compression-level: 0 include-hidden-files: true path: ${{ steps.retained.outputs.directory }}/* if-no-files-found: error diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index 766a395821..4723f15370 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -24,8 +24,8 @@ en: 5b4f0f80a117f202 zh: ff05be196c181087 /desktop-packaging/windows-actions-packaging: - en: e628d75c47317979 - zh: 17055233501f4df6 + en: af6991014e97911e + zh: f30c4d6cdcd8e8c6 /desktop-packaging/macos-self-hosted-packaging: en: a3af9523fdc60036 zh: 5e18d4740e9b7f57 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index 92eb557309..4787ec38fc 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -119,6 +119,8 @@ For cache measurements, `cache_namespace` selects a retained subdirectory for bo The dependency-install and packaging steps normalize their own shell to normal CPU, I/O and memory priority before launching children, and record all three values before and after adjustment. Failure to establish these values stops the step. This does not modify the runner task, account, startup triggers or other processes. +Installer uploads store the already-compressed release files without ZIP recompression; this preserves installer bytes and signatures. Diagnostic uploads retain their ordinary compression. + ## macOS self-hosted packaging diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index c5d02a9073..75baa9035f 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -119,6 +119,8 @@ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-s 依赖安装和打包步骤在启动子进程前,将自身 shell 的 CPU、I/O 和内存优先级调整为普通值,并记录调整前后的三项值;无法确认调整成功时停止该步骤。这不会修改 runner 任务、账户、启动触发器或其他进程。 +安装器上传直接存储已经压缩的发布文件,不再进行 ZIP 二次压缩;安装器字节和签名保持不变。诊断日志上传保留通常的压缩方式。 + ## macOS 自托管打包 diff --git a/scripts/windows-package-workflow.spec.ts b/scripts/windows-package-workflow.spec.ts index 1b3efd3ba6..58bb948ada 100644 --- a/scripts/windows-package-workflow.spec.ts +++ b/scripts/windows-package-workflow.spec.ts @@ -113,7 +113,7 @@ it('retains failure diagnostics and uploads only the selected successful install }) for (const mode of ['unsigned', 'signed']) { const upload = uploads.find(step => step.if === `success() && inputs.signing == '${mode}'`)! - expect(upload.with).toMatchObject({ path: '${{ steps.retained.outputs.directory }}/*', 'if-no-files-found': 'error' }) + expect(upload.with).toMatchObject({ path: '${{ steps.retained.outputs.directory }}/*', 'if-no-files-found': 'error', 'compression-level': 0 }) } }) From 73574aff590613dac6977ec7f46855247db0f0a4 Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:05:20 +0800 Subject: [PATCH 21/46] perf(windows): prepare Electron and primary runtime concurrently --- apps/desktop/packaging/README.i18n.yaml | 4 +- apps/desktop/packaging/README.md | 2 + apps/desktop/packaging/README.zh.md | 2 + apps/desktop/scripts/prepare-runtime.ts | 13 ++- .../desktop/tests/runtime-preparation.spec.ts | 99 ++++++++++++++++++- 5 files changed, 113 insertions(+), 7 deletions(-) diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index 4723f15370..81eedc8286 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -48,8 +48,8 @@ en: d87a53ab0f8b0db9 zh: c3b2b96085f29d3b /desktop-packaging/desktop-runtime-preparation: - en: bce2ee195a876985 - zh: ae45df21c23e86c2 + en: 59783a64552d4464 + zh: d7876d51b4015578 /desktop-packaging/installation-and-release-verification: en: eb35eb973b796643 zh: c1c52311632b7296 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index 4787ec38fc..aa38003bd3 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -270,6 +270,8 @@ NSIS removes its temporary extraction tree during installation, before the compl ## Desktop runtime preparation +Windows prepares Electron and primary-runtime concurrently; macOS prepares them serially. CLI preparation and `versions.json` publication require both payloads to succeed. If either Windows payload fails, preparation waits for the other to settle before reporting failure and publishes neither CLI resources nor versions. Nested preparation timings overlap and must not be added together. + `DSH_DESKTOP_PACKAGE_CACHE_DIR` in the packaging process environment selects a nonempty absolute directory for shared `downloads` and `pnpm-store` caches; omission uses `.desktop-build`. Temporary installation projects, lockfiles and configuration remain separate per run. Runtime installation verifies store integrity and copies package files so later signing cannot modify cached bytes; cleanup retains these caches. Windows Actions uses `runner.tool_cache/dsh-desktop-package-cache`, while repository dependency installation uses a separate `.pnpm-store` at the workspace drive root. Both installation logs identify their store and report package reuse. A fresh runtime lockfile still resolves dependencies on every run. Desktop [patch policy](../scripts/runtime-patch-policy.ts) explicitly classifies every workspace patch as shared, workspace-only, or runtime-only. Shared entries reuse the root patch bytes and verify their hash against the root lockfile; runtime-only entries use separate repository files without changing workspace installation. Workspace-only patches belong to build tools or dependencies already embedded in client bundles. Unclassified or stale entries, missing files, changed hashes, incompatible resolved versions, and workspace-only packages entering the runtime stop packaging. The [preparation script](../scripts/prepare-runtime-patches.ts) writes only to the temporary project; pnpm applies the selected patches and rejects application failures. diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index 75baa9035f..d9c31c29de 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -270,6 +270,8 @@ NSIS 在安装阶段清理临时解压目录,完成后才显示完成页或自 ## Desktop 运行时准备 +Windows 并行准备 Electron 和 primary-runtime;macOS 串行准备二者。只有两份运行时资源均成功,才会准备 CLI 并写入 `versions.json`。任一 Windows 运行时准备失败时,流程会等待另一路结束后再报告失败,且不写入 CLI 资源或版本记录。嵌套准备阶段的计时存在重叠,不能直接相加。 + 打包进程环境中的 `DSH_DESKTOP_PACKAGE_CACHE_DIR` 为共享的 `downloads` 和 `pnpm-store` 缓存指定非空绝对目录;省略时使用 `.desktop-build`。临时安装项目、lockfile 和配置仍逐次独立创建。运行时安装校验 store 完整性并复制包文件,防止后续签名修改缓存字节;清理时保留这些缓存。Windows Actions 使用 `runner.tool_cache/dsh-desktop-package-cache`,仓库依赖安装则使用工作区盘根目录下独立的 `.pnpm-store`。两次安装日志均标明各自的 store 并报告包复用情况。每次运行仍通过新的运行时 lockfile 解析依赖。 Desktop [补丁策略](../scripts/runtime-patch-policy.ts) 将每份工作区补丁明确归为共享、仅工作区或仅运行时。共享项复用根目录补丁字节,并对照根锁文件校验 hash;仅运行时项使用仓库内的独立文件,不改变工作区安装。仅工作区补丁用于构建工具或已嵌入客户端 bundle 的依赖。未分类或过期的条目、文件缺失、hash 改变、解析版本不兼容,以及仅工作区包进入运行时,都会阻止打包。[准备脚本](../scripts/prepare-runtime-patches.ts) 只写入临时项目;pnpm 负责应用选中的补丁,应用失败时会报错。 diff --git a/apps/desktop/scripts/prepare-runtime.ts b/apps/desktop/scripts/prepare-runtime.ts index ba92fef0e6..4e33853272 100644 --- a/apps/desktop/scripts/prepare-runtime.ts +++ b/apps/desktop/scripts/prepare-runtime.ts @@ -53,9 +53,18 @@ async function main(): Promise { const target = desktopTargetPlatform(resolveDesktopBuildTarget()) rmSync(RUNTIME_ROOT, { recursive: true, force: true }) mkdirSync(RUNTIME_ROOT, { recursive: true }) - const nodeVersion = await prepareElectron(target) - await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'prepare:primary-runtime', + const preparePrimary = () => packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'prepare:primary-runtime', () => preparePrimaryRuntime({ deferSmoke: values['defer-primary-runtime-smoke'] })) + let nodeVersion: string + if (target.platform === 'win32') { + const [electron, primary] = await Promise.allSettled([prepareElectron(target), preparePrimary()]) + if (electron.status === 'rejected') throw electron.reason + if (primary.status === 'rejected') throw primary.reason + nodeVersion = electron.value + } else { + nodeVersion = await prepareElectron(target) + await preparePrimary() + } const { pnpm } = await readPrimaryRuntime(join(RUNTIME_ROOT, 'primary-runtime')) if (pnpm === undefined) throw new Error('desktop runtime: primary-runtime manifest has no pnpm version') await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'prepare:cli', async () => { prepareCli(target) }) diff --git a/apps/desktop/tests/runtime-preparation.spec.ts b/apps/desktop/tests/runtime-preparation.spec.ts index d16dd592c2..5da6e9815d 100644 --- a/apps/desktop/tests/runtime-preparation.spec.ts +++ b/apps/desktop/tests/runtime-preparation.spec.ts @@ -12,6 +12,7 @@ const state = vi.hoisted(() => ({ pnpm: '11.7.0' as string | undefined, failure: undefined as 'electron' | 'primary' | 'cli' | undefined, preparePrimary: vi.fn(), + extractElectron: vi.fn(), commandLink: vi.fn(), })) vi.mock('../scripts/desktop-build-paths.mjs', async importOriginal => ({ @@ -22,9 +23,7 @@ vi.mock('../scripts/desktop-build-paths.mjs', async importOriginal => ({ }), })) vi.mock('@electron/get', () => ({ downloadArtifact: async () => 'electron.zip' })) -vi.mock('extract-zip', () => ({ default: async () => { - if (state.failure === 'electron') throw new Error('Electron extraction failed') -} })) +vi.mock('extract-zip', () => ({ default: state.extractElectron })) vi.mock('node:child_process', async importOriginal => ({ ...await importOriginal(), execFileSync: (file: string) => file === '/usr/libexec/PlistBuddy' ? '12.0\n' : '24.18.1\n', @@ -54,6 +53,9 @@ beforeEach(() => { state.failure = undefined process.argv = [process.execPath, 'prepare-runtime.ts'] vi.stubEnv('DSH_DESKTOP_PACKAGING_RUN_DIR', undefined) + state.extractElectron.mockImplementation(async () => { + if (state.failure === 'electron') throw new Error('Electron extraction failed') + }) state.preparePrimary.mockImplementation(async () => { if (state.failure === 'primary') throw new Error('primary preparation failed') const root = join(state.root, 'runtime', 'primary-runtime') @@ -118,3 +120,94 @@ it.each([ await expect(import('../scripts/prepare-runtime.ts')).rejects.toThrow(error) expect(existsSync(join(state.root, 'runtime', 'versions.json'))).toBe(false) }) + +it.each(['electron', 'primary'] as const)('overlaps Windows payloads and waits for %s to finish last before publishing CLI and versions', async (last) => { + state.target = 'win-x64' + const electron = Promise.withResolvers() + const primary = Promise.withResolvers() + const started = new Set() + const finished = new Set() + const preparePrimary = state.preparePrimary.getMockImplementation()! + state.extractElectron.mockImplementation(async () => { + started.add('electron') + await electron.promise + finished.add('electron') + }) + state.preparePrimary.mockImplementation(async () => { + started.add('primary') + await primary.promise + await preparePrimary() + finished.add('primary') + }) + const outcome = import('../scripts/prepare-runtime.ts').then(() => undefined, (error: unknown) => error) + try { + await vi.waitFor(() => { expect([...started].sort()).toEqual(['electron', 'primary']) }, { timeout: 5000 }) + ;(last === 'electron' ? primary : electron).resolve(undefined) + await vi.waitFor(() => { expect(finished.size).toBe(1) }, { timeout: 5000 }) + expect(existsSync(join(state.root, 'runtime', 'cli'))).toBe(false) + expect(existsSync(join(state.root, 'runtime', 'versions.json'))).toBe(false) + ;(last === 'electron' ? electron : primary).resolve(undefined) + expect(await outcome).toBeUndefined() + expect(existsSync(join(state.root, 'runtime', 'cli', 'command-manager.js'))).toBe(true) + expect(existsSync(join(state.root, 'runtime', 'versions.json'))).toBe(true) + } finally { + electron.resolve(undefined) + primary.resolve(undefined) + await outcome + } +}) + +it.each(['electron', 'primary'] as const)('drains the sibling after Windows %s failure without publishing CLI or versions', async (failed) => { + state.target = 'win-x64' + const electron = Promise.withResolvers() + const primary = Promise.withResolvers() + const started = new Set() + const completed = new Set() + const failure = new Error(`${failed} payload failed`) + const prepare = async (name: 'electron' | 'primary', barrier: Promise) => { + started.add(name) + try { await barrier } finally { completed.add(name) } + } + state.extractElectron.mockImplementation(() => prepare('electron', electron.promise)) + state.preparePrimary.mockImplementation(() => prepare('primary', primary.promise)) + const outcome = import('../scripts/prepare-runtime.ts').then( + () => ({ error: undefined, completed: [...completed].sort() }), + (error: unknown) => ({ error, completed: [...completed].sort() }), + ) + try { + await vi.waitFor(() => { expect([...started].sort()).toEqual(['electron', 'primary']) }, { timeout: 5000 }) + ;(failed === 'electron' ? electron : primary).reject(failure) + await vi.waitFor(() => { expect(completed.has(failed)).toBe(true) }, { timeout: 5000 }) + expect(existsSync(join(state.root, 'runtime', 'cli'))).toBe(false) + expect(existsSync(join(state.root, 'runtime', 'versions.json'))).toBe(false) + ;(failed === 'electron' ? primary : electron).resolve(undefined) + expect(await outcome).toEqual({ error: failure, completed: ['electron', 'primary'] }) + expect(existsSync(join(state.root, 'runtime', 'cli'))).toBe(false) + expect(existsSync(join(state.root, 'runtime', 'versions.json'))).toBe(false) + } finally { + electron.resolve(undefined) + primary.resolve(undefined) + await outcome + } +}) + +it.each(['mac-arm64', 'mac-x64'] as const)('keeps %s primary preparation after Electron extraction', async (target) => { + state.target = target + const electron = Promise.withResolvers() + let extracting = false + state.extractElectron.mockImplementation(async () => { + extracting = true + await electron.promise + }) + const outcome = import('../scripts/prepare-runtime.ts').then(() => undefined, (error: unknown) => error) + try { + await vi.waitFor(() => { expect(extracting).toBe(true) }, { timeout: 5000 }) + expect(state.preparePrimary).not.toHaveBeenCalled() + electron.resolve(undefined) + expect(await outcome).toBeUndefined() + expect(state.preparePrimary).toHaveBeenCalledOnce() + } finally { + electron.resolve(undefined) + await outcome + } +}) From 59b24da527e78d4f171c1359dc9a88f8103c206c Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:08:12 +0800 Subject: [PATCH 22/46] fix(ci): pin Windows packaging helpers to workflow revision --- .github/workflows/windows-package.yml | 23 ++++++-- apps/desktop/packaging/README.i18n.yaml | 4 +- apps/desktop/packaging/README.md | 2 +- apps/desktop/packaging/README.zh.md | 2 +- .../scripts/windows-packaging-priority.ps1 | 6 ++- .../tests/windows-packaging-priority.ps1 | 16 +++++- scripts/windows-package-workflow.spec.ts | 52 +++++++++++++++++-- 7 files changed, 89 insertions(+), 16 deletions(-) diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index f22dba2bd0..c38fd7d5a0 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -96,6 +96,20 @@ jobs: } node -e "const fs = require('node:fs'); fs.rmSync(process.argv[1], { recursive: true, force: true, maxRetries: 3 }); fs.mkdirSync(process.argv[1])" $workspace if ($LASTEXITCODE -ne 0) { throw 'Checkout cleanup failed' } + - name: Checkout workflow helpers + uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + ref: ${{ github.workflow_sha }} + sparse-checkout: apps/desktop/scripts + - name: Stage workflow helpers + run: | + $helpers = Join-Path $env:RUNNER_TEMP "packaging-helpers-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT" + New-Item -ItemType Directory -Path $helpers -ErrorAction Stop | Out-Null + foreach ($name in @('windows-packaging-priority.ps1', 'windows-build-workspace.mjs')) { + Copy-Item -LiteralPath (Join-Path 'apps/desktop/scripts' $name) -Destination $helpers -ErrorAction Stop + } + "PACKAGE_CI_HELPERS=$helpers" >> $env:GITHUB_ENV - uses: actions/checkout@v7.0.1 id: checkout with: @@ -119,7 +133,6 @@ jobs: const branch=cp.execFileSync('git',['rev-parse','--abbrev-ref','HEAD'],{encoding:'utf8'}).trim(); if(branch==='HEAD' || branch!==ref || ref.startsWith('refs/')) throw Error('Select a branch in this repository, not a SHA or PR ref'); } - if(!fs.existsSync('apps/desktop/scripts/windows-packaging-priority.ps1')) throw Error('Source lacks Windows packaging priority support; select a newer release'); if(!fs.readFileSync('apps/desktop/scripts/desktop-ci-version.mjs','utf8').includes('PACKAGE_VERSION_MODE')) throw Error('Source lacks Desktop channel/version CI support; select a newer release');" - uses: pnpm/action-setup@v5.0.0 with: @@ -135,7 +148,7 @@ jobs: if (!$env:PACKAGE_BUILD_ROOT) { "directory=$env:GITHUB_WORKSPACE" >> $env:GITHUB_OUTPUT } else { - node apps/desktop/scripts/windows-build-workspace.mjs + node "$env:PACKAGE_CI_HELPERS/windows-build-workspace.mjs" exit $LASTEXITCODE } - name: Configure persistent pnpm store @@ -156,7 +169,7 @@ jobs: - name: Install locked dependencies working-directory: ${{ steps.build_workspace.outputs.directory }} run: | - & ./apps/desktop/scripts/windows-packaging-priority.ps1 + & "$env:PACKAGE_CI_HELPERS/windows-packaging-priority.ps1" -BestEffort node -e "const os=require('node:os'); console.log(JSON.stringify({node:process.version,availableParallelism:os.availableParallelism(),logicalCpus:os.cpus().length,totalMemoryBytes:os.totalmem(),freeMemoryBytes:os.freemem()}))" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } pnpm store path @@ -184,7 +197,7 @@ jobs: PACKAGE_VERSION: ${{ steps.settings.outputs.version }} PACKAGE_BUILD_TEMP: ${{ steps.build_workspace.outputs.temporaryDirectory }} run: | - & ./apps/desktop/scripts/windows-packaging-priority.ps1 + & "$env:PACKAGE_CI_HELPERS/windows-packaging-priority.ps1" -BestEffort if ($env:PACKAGE_BUILD_TEMP) { $env:TEMP = $env:PACKAGE_BUILD_TEMP $env:TMP = $env:PACKAGE_BUILD_TEMP @@ -233,4 +246,4 @@ jobs: if: success() && inputs.build_root != '' env: PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }} - run: node apps/desktop/scripts/windows-build-workspace.mjs --remove + run: node "$env:PACKAGE_CI_HELPERS/windows-build-workspace.mjs" --remove diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index d4dc3c936b..3a2f5aad1a 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -24,8 +24,8 @@ en: 5b4f0f80a117f202 zh: ff05be196c181087 /desktop-packaging/windows-actions-packaging: - en: af6991014e97911e - zh: f30c4d6cdcd8e8c6 + en: cb554f51438381b1 + zh: f7a40d65fc5a57fc /desktop-packaging/macos-self-hosted-packaging: en: 734d1df945da8f45 zh: 2b17a3039efdde78 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index b613950d99..3277ee277c 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -119,7 +119,7 @@ For path-sensitive Windows build measurements, `build_root` optionally selects a For cache measurements, `cache_namespace` selects a retained subdirectory for both dependency stores and upstream downloads; leave it empty for normal shared caches. Use a new name for a cold sample and repeat that name for warm samples. Names contain 1–64 lowercase letters, digits or hyphens, start with a letter or digit, and cannot be Windows device names. This does not reset the separate signature cache or OS file cache; report those states and Job start-to-completion time alongside phase timings and artifact size. Before dependency installation, the workflow records the Node version, available CPU parallelism, logical CPU count, and total/free memory. -The dependency-install and packaging steps normalize their own shell to normal CPU, I/O and memory priority before launching children, and record all three values before and after adjustment. Failure to establish these values stops the step. This does not modify the runner task, account, startup triggers or other processes. +The dependency-install and packaging steps normalize their own shell to normal CPU, I/O and memory priority before launching children. Priority and isolated-workspace helpers are copied from the exact workflow commit into a run-specific temporary directory, independently of the selected product source. Missing helpers stop CI setup; unavailable priority adjustment emits a warning and marks scheduling unsuitable for performance comparison, but does not stop packaging. This does not modify the runner task or other processes. Version, preset and retained-artifact adapters remain tied to product source and its formats. Installer uploads store the already-compressed release files without ZIP recompression; this preserves installer bytes and signatures. Diagnostic uploads retain their ordinary compression. diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index 7d2354fe26..ce70ef9ec6 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -119,7 +119,7 @@ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-s 测量缓存时,`cache_namespace` 为两个依赖 store 和上游下载选择保留的子目录;留空使用正常共享缓存。新名称用于冷缓存采样,再次使用同名用于热缓存采样。名称由 1–64 个小写字母、数字或连字符组成,以字母或数字开头,且不能为 Windows 设备名。这不会重置独立的签名缓存或操作系统文件缓存;应将其状态、Job 开始至完成的总耗时与阶段耗时和产物体积一并报告。workflow 在依赖安装前记录 Node 版本、可用 CPU 并行数、逻辑 CPU 数及总内存和空闲内存。 -依赖安装和打包步骤在启动子进程前,将自身 shell 的 CPU、I/O 和内存优先级调整为普通值,并记录调整前后的三项值;无法确认调整成功时停止该步骤。这不会修改 runner 任务、账户、启动触发器或其他进程。 +依赖安装和打包步骤在启动子进程前,将自身 shell 的 CPU、I/O 和内存优先级调整为普通值。优先级及独立构建目录辅助脚本从 workflow 的精确提交复制到本轮临时目录,与选中的产品源码分离。缺失辅助脚本会终止 CI 准备;无法调整优先级时明确告警并标记调度条件不适合性能对比,但继续打包。这不会修改 runner 任务或其他进程。版本、preset 和产物留存适配器仍跟随产品源码及其格式。 安装器上传直接存储已经压缩的发布文件,不再进行 ZIP 二次压缩;安装器字节和签名保持不变。诊断日志上传保留通常的压缩方式。 diff --git a/apps/desktop/scripts/windows-packaging-priority.ps1 b/apps/desktop/scripts/windows-packaging-priority.ps1 index ed4ae4a49e..2e926f030b 100644 --- a/apps/desktop/scripts/windows-packaging-priority.ps1 +++ b/apps/desktop/scripts/windows-packaging-priority.ps1 @@ -1,6 +1,6 @@ # Normalize only this workflow shell and the children it subsequently launches. [CmdletBinding()] -param([switch]$InspectOnly) +param([switch]$InspectOnly, [switch]$BestEffort) $ErrorActionPreference = 'Stop' if (-not $IsWindows) { throw 'Packaging process priority requires Windows.' } @@ -50,6 +50,10 @@ try { if (-not $InspectOnly -and ($after.cpu -ne 'Normal' -or $after.io -ne 2 -or $after.memory -ne 5)) { throw 'Packaging process did not reach normal CPU, I/O and memory priority.' } +} catch { + if (-not $BestEffort) { throw } + Write-Warning "Packaging priority adjustment unavailable; performance comparison is invalid: $($_.Exception.Message)" + [ordered]@{ event = 'packaging-process-priority'; comparable = $false } | ConvertTo-Json -Compress } finally { $process.Dispose() } diff --git a/apps/desktop/tests/windows-packaging-priority.ps1 b/apps/desktop/tests/windows-packaging-priority.ps1 index 7a0e827acb..3a7b6981c6 100644 --- a/apps/desktop/tests/windows-packaging-priority.ps1 +++ b/apps/desktop/tests/windows-packaging-priority.ps1 @@ -1,9 +1,19 @@ # Exercise priority inheritance in disposable children without changing the test host or runner. [CmdletBinding()] -param([switch]$Child) +param([switch]$Child, [switch]$Failure) $ErrorActionPreference = 'Stop' if (-not $IsWindows) { throw 'Requires PowerShell 7 on Windows.' } $helper = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../scripts/windows-packaging-priority.ps1')) +if ($Failure) { + Add-Type 'public static class DshPackagingPriority { public static int Read(int kind) { throw new System.InvalidOperationException("fixture priority access denied"); } }' + $warnings = @() + $report = & $helper -BestEffort -WarningVariable warnings 3>$null | ConvertFrom-Json + if ($report.comparable -ne $false -or $warnings.Count -ne 1) { throw 'Missing degraded scheduling report.' } + $rejected = $false + try { & $helper } catch { $rejected = $true } + if (-not $rejected) { throw 'Strict priority inspection swallowed failure.' } + exit 0 +} if ($Child) { $null = & $helper -InspectOnly $self = [Diagnostics.Process]::GetCurrentProcess() @@ -51,4 +61,6 @@ try { } $process.Dispose() } -Write-Output 'Packaging priority normalization and child inheritance passed.' +& (Join-Path $PSHOME 'pwsh.exe') -NoProfile -File $PSCommandPath -Failure +if ($LASTEXITCODE -ne 0) { throw 'Priority failure policy test failed.' } +Write-Output 'Packaging priority normalization, child inheritance and failure policy passed.' diff --git a/scripts/windows-package-workflow.spec.ts b/scripts/windows-package-workflow.spec.ts index 158dd60500..4c707bc511 100644 --- a/scripts/windows-package-workflow.spec.ts +++ b/scripts/windows-package-workflow.spec.ts @@ -127,7 +127,7 @@ it('routes all packaging consumers to the selected workspace and removes it only expect(selected.run).toContain('if (!$env:PACKAGE_BUILD_ROOT)') expect(selected.run).toContain('"directory=$env:GITHUB_WORKSPACE" >> $env:GITHUB_OUTPUT') expect(selected.run).toContain('} else {') - expect(selected.run).toContain('node apps/desktop/scripts/windows-build-workspace.mjs') + expect(selected.run).toContain('node "$env:PACKAGE_CI_HELPERS/windows-build-workspace.mjs"') expect(selected.run).toContain('exit $LASTEXITCODE') const index = job.steps.indexOf(selected) expect(index).toBeGreaterThan(job.steps.findIndex(step => step.uses?.startsWith('actions/setup-node@'))) @@ -154,7 +154,7 @@ it('routes all packaging consumers to the selected workspace and removes it only name: 'Remove isolated packaging workspace', if: "success() && inputs.build_root != ''", env: { PACKAGE_SOURCE_SHA: '${{ steps.checkout.outputs.commit }}' }, - run: 'node apps/desktop/scripts/windows-build-workspace.mjs --remove', + run: 'node "$env:PACKAGE_CI_HELPERS/windows-build-workspace.mjs" --remove', }) expect(cleanup['working-directory']).toBeUndefined() for (const upload of job.steps.filter(step => step.uses?.startsWith('actions/upload-artifact@'))) { @@ -163,9 +163,9 @@ it('routes all packaging consumers to the selected workspace and removes it only }) it('normalizes priority in the dependency and packaging shells before launching their children', () => { - const command = '& ./apps/desktop/scripts/windows-packaging-priority.ps1' + const command = '& "$env:PACKAGE_CI_HELPERS/windows-packaging-priority.ps1" -BestEffort' expect(job.steps.find(entry => entry.name === 'Validate selected source and adapter support')!.run) - .toContain("fs.existsSync('apps/desktop/scripts/windows-packaging-priority.ps1')") + .not.toContain('windows-packaging-priority.ps1') for (const name of ['Install locked dependencies', 'Build installer and verify packaged runtime']) { const step = job.steps.find(entry => entry.name === name)! expect(step.run!.trimStart().startsWith(command + '\n')).toBe(true) @@ -239,3 +239,47 @@ it.skipIf(process.platform !== 'win32')('keeps the dependency store stable acros rmSync(directory, { recursive: true, force: true, maxRetries: 3 }) } }) + +it('pins environment helpers to the workflow commit before selecting product source', () => { + const helper = job.steps.find(step => step.name === 'Checkout workflow helpers')! + const stage = job.steps.find(step => step.name === 'Stage workflow helpers')! + const source = job.steps.find(step => step.id === 'checkout')! + expect(helper.with).toMatchObject({ ref: '${{ github.workflow_sha }}', 'persist-credentials': false }) + expect(job.steps.indexOf(helper)).toBeLessThan(job.steps.indexOf(stage)) + expect(job.steps.indexOf(stage)).toBeLessThan(job.steps.indexOf(source)) + expect(stage.run).toContain('RUNNER_TEMP') + expect(stage.run).toContain('windows-build-workspace.mjs') + expect(stage.run).toContain('windows-packaging-priority.ps1') + for (const step of job.steps.filter(step => step.name !== 'Stage workflow helpers')) { + expect(step.run ?? '').not.toContain('./apps/desktop/scripts/windows-packaging-priority.ps1') + expect(step.run ?? '').not.toContain('node apps/desktop/scripts/windows-build-workspace.mjs') + } +}) + +it.skipIf(process.platform !== 'win32')('stages helpers that remain usable when product source lacks them', () => { + const directory = mkdtempSync(resolve(tmpdir(), 'workflow-helpers-')) + try { + const source = resolve(directory, 'source') + const scripts = resolve(source, 'apps/desktop/scripts') + const temporary = resolve(directory, 'temporary') + mkdirSync(scripts, { recursive: true }) + mkdirSync(temporary) + for (const name of ['windows-packaging-priority.ps1', 'windows-build-workspace.mjs']) { + writeFileSync(resolve(scripts, name), readFileSync(resolve(root, 'apps/desktop/scripts', name))) + } + const stage = resolve(directory, 'stage.ps1') + writeFileSync(stage, job.steps.find(step => step.name === 'Stage workflow helpers')!.run!) + const env = { ...process.env, RUNNER_TEMP: temporary, GITHUB_RUN_ID: '123', GITHUB_RUN_ATTEMPT: '1', GITHUB_ENV: resolve(directory, 'env') } + execFileSync('pwsh', ['-NoProfile', '-File', stage], { cwd: source, env, stdio: 'pipe' }) + rmSync(scripts, { recursive: true }) + const helper = resolve(temporary, 'packaging-helpers-123-1/windows-packaging-priority.ps1') + expect(execFileSync('pwsh', ['-NoProfile', '-File', helper, '-InspectOnly'], { cwd: source, stdio: 'pipe' }).toString()) + .toContain('packaging-process-priority') + expect(existsSync(resolve(temporary, 'packaging-helpers-123-1/windows-build-workspace.mjs'))).toBe(true) + expect(() => execFileSync('pwsh', ['-NoProfile', '-File', stage], { + cwd: source, env: { ...env, GITHUB_RUN_ATTEMPT: '2' }, stdio: 'pipe', + })).toThrow() + } finally { + rmSync(directory, { recursive: true, force: true, maxRetries: 3 }) + } +}) From 32bbe4fc8a1765e4836b45998d08a555d8e343f8 Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:38:57 +0800 Subject: [PATCH 23/46] fix(windows): check token public identity before signing --- apps/desktop/scripts/windows-sign.mjs | 4 +- .../scripts/windows-signing-preflight.ts | 10 +- .../desktop/scripts/windows-token-identity.ts | 87 +++++++++++++++ .../scripts/windows-token-public-key.cs | 94 ++++++++++++++++ .../scripts/windows-token-public-key.ps1 | 4 + .../fixtures/windows-token-public-key-fake.cs | 49 +++++++++ apps/desktop/tests/windows-sign.spec.ts | 4 +- .../tests/windows-signing-preflight.spec.ts | 19 +++- .../tests/windows-token-identity.spec.ts | 101 ++++++++++++++++++ apps/desktop/windows-signing/README.i18n.yaml | 4 +- apps/desktop/windows-signing/README.md | 2 + apps/desktop/windows-signing/README.zh.md | 2 + 12 files changed, 374 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/scripts/windows-token-identity.ts create mode 100644 apps/desktop/scripts/windows-token-public-key.cs create mode 100644 apps/desktop/scripts/windows-token-public-key.ps1 create mode 100644 apps/desktop/tests/fixtures/windows-token-public-key-fake.cs create mode 100644 apps/desktop/tests/windows-token-identity.spec.ts diff --git a/apps/desktop/scripts/windows-sign.mjs b/apps/desktop/scripts/windows-sign.mjs index 3ebd199615..78459c4c1f 100644 --- a/apps/desktop/scripts/windows-sign.mjs +++ b/apps/desktop/scripts/windows-sign.mjs @@ -135,7 +135,9 @@ export function createRedactedWindowsSigningError(error, path, secrets) { const stderr = record !== undefined && 'stderr' in record ? redactedSigningOutput(record.stderr, secrets).trim() : '' - return new Error(`Windows release signing failed for ${path}${code}${stderr === '' ? '' : `: ${stderr}`}`) + return new Error(`Windows release signing failed for ${path}${code}${stderr === '' ? '' : `: ${stderr}`}. ` + + 'Signing stopped. After replacing the UKey, check the certificate, container, SafeNet provider and user PIN. ' + + 'This error does not establish device lock status or whether PIN retries were consumed; inspect the retained interlock before operator recovery.') } /** diff --git a/apps/desktop/scripts/windows-signing-preflight.ts b/apps/desktop/scripts/windows-signing-preflight.ts index 6076e4cd4e..48d65b7b91 100644 --- a/apps/desktop/scripts/windows-signing-preflight.ts +++ b/apps/desktop/scripts/windows-signing-preflight.ts @@ -8,6 +8,7 @@ import { promisify } from 'node:util' import { createWindowsTokenSigner, scrubWindowsSigningEnvironment } from './windows-sign.mjs' import { inspectWindowsRuntimeSignature, type WindowsRuntimeSignature } from './windows-runtime-signature.mjs' import { failPackagingRun, recordPackagingEvent } from './packaging-run.mjs' +import { inspectWindowsTokenIdentity, windowsTokenIdentityFailure } from './windows-token-identity.ts' const PROBE_SOURCE = 'internal static class SigningProbe { private static int Main() { return 0; } }\n' @@ -18,6 +19,7 @@ interface SigningPreflightOptions { compile?: (compiler: string, source: string, output: string, environment: NodeJS.ProcessEnv) => Promise sign?: ReturnType inspect?: (path: string) => Promise + inspectIdentity?: typeof inspectWindowsTokenIdentity } async function compileProbe(compiler: string, source: string, output: string, environment: NodeJS.ProcessEnv): Promise { @@ -28,7 +30,7 @@ async function compileProbe(compiler: string, source: string, output: string, en } /** - * Sign one newly compiled, never-executed probe and verify its timestamp and configured certificate. + * Match public container keys before signing one never-executed probe and verifying its certificate and timestamp. * @param options Supervised evidence directory, file-owned settings and isolated test adapters. * @returns Resolves after one signature and verification; failures retain evidence; timestamp attempts never repeat hardware signing. */ @@ -71,6 +73,12 @@ export async function preflightWindowsSigning(options: SigningPreflightOptions): compilerSha256: createHash('sha256').update(await readFile(compiler)).digest('hex'), signToolSha256: createHash('sha256').update(await readFile(environment.DSH_DESKTOP_WINDOWS_SIGNTOOL!)).digest('hex'), certificateSha256: createHash('sha256').update(certificate).digest('hex') }) + const identity = await (options.inspectIdentity ?? inspectWindowsTokenIdentity)({ + certificate, container: environment.DSH_DESKTOP_WINDOWS_KEY_CONTAINER!.trim(), + environment: scrubWindowsSigningEnvironment(environment), + }) + record({ type: 'signing-preflight-public-identity', result: identity, pinSupplied: false }) + if (identity !== 'matched') throw new Error(windowsTokenIdentityFailure(identity)) await (options.compile ?? compileProbe)(compiler, source, output, environment) const inspect = options.inspect ?? inspectWindowsRuntimeSignature if ((await inspect(output)).status !== 'NotSigned') throw new Error('Windows signing preflight requires a new unsigned probe') diff --git a/apps/desktop/scripts/windows-token-identity.ts b/apps/desktop/scripts/windows-token-identity.ts new file mode 100644 index 0000000000..3171163c20 --- /dev/null +++ b/apps/desktop/scripts/windows-token-identity.ts @@ -0,0 +1,87 @@ +/** Compare CSP public material without supplying a PIN or invoking a signing operation. */ +import { execFile } from 'node:child_process' +import { createPublicKey, X509Certificate } from 'node:crypto' +import { promisify } from 'node:util' +import { fileURLToPath } from 'node:url' +import { scrubWindowsSigningEnvironment } from './windows-sign.mjs' + +/** Public-data results do not attest physical device presence, authentication or remaining PIN attempts. */ +export type WindowsTokenIdentity = 'matched' | 'certificate-mismatch' | 'provider-unavailable' | 'container-unavailable' | 'unknown' + +interface PublicKeyQuery { + container: string + environment: NodeJS.ProcessEnv +} + +async function queryPublicKeys({ container, environment }: PublicKeyQuery): Promise { + const { NODE_OPTIONS: _options, NODE_PATH: _path, ...safeEnvironment } = scrubWindowsSigningEnvironment(environment) + const { stdout, stderr } = await promisify(execFile)('pwsh', ['-NoProfile', '-NonInteractive', '-File', + fileURLToPath(new URL('./windows-token-public-key.ps1', import.meta.url))], { + env: { ...safeEnvironment, DSH_TOKEN_PUBLIC_CONTAINER: container }, + encoding: 'utf8', windowsHide: true, timeout: 15_000, maxBuffer: 64 * 1024, + }) + if (stderr) throw new Error('Public-key query returned diagnostics') + return stdout +} + +function publicBlobKey(value: string): Buffer { + const bytes = Buffer.from(value, 'base64') + if (bytes.toString('base64') !== value || bytes.length < 20 || bytes[0] !== 6 || bytes[1] !== 2 + || bytes.readUInt16LE(2) !== 0 || ![0x2400, 0xa400].includes(bytes.readUInt32LE(4)) + || bytes.readUInt32LE(8) !== 0x31415352) throw new Error('Invalid RSA public blob') + const bits = bytes.readUInt32LE(12) + const exponent = bytes.readUInt32LE(16) + if (bits < 512 || bits % 8 !== 0 || bytes.length !== 20 + bits / 8 || exponent < 3 || exponent % 2 === 0) { + throw new Error('Invalid RSA public parameters') + } + const exponentBytes = Buffer.alloc(4) + exponentBytes.writeUInt32BE(exponent) + const first = exponentBytes.findIndex(byte => byte !== 0) + return createPublicKey({ format: 'jwk', key: { kty: 'RSA', + n: Buffer.from(bytes.subarray(20)).reverse().toString('base64url'), + e: exponentBytes.subarray(first).toString('base64url') } }).export({ format: 'der', type: 'spki' }) +} + +/** + * Read both CSP public key slots and compare them with the configured certificate; never sign or retry. + * @param options Certificate bytes, container, process environment and optional isolated public-query adapter. + * @returns Matched public information, a specific unavailable/mismatch result, or unknown on incomplete/invalid output. + */ +export async function inspectWindowsTokenIdentity(options: PublicKeyQuery & { + certificate: Uint8Array + query?: (input: PublicKeyQuery) => Promise +}): Promise { + try { + const value: unknown = JSON.parse(await (options.query ?? queryPublicKeys)({ + container: options.container, environment: options.environment, + })) + if (!value || typeof value !== 'object' || !('status' in value)) return 'unknown' + if (value.status === 'provider-unavailable' || value.status === 'container-unavailable') return value.status + if (value.status !== 'public-keys' || !('publicKeys' in value) || !Array.isArray(value.publicKeys) + || value.publicKeys.length < 1 || value.publicKeys.length > 2) return 'unknown' + const keys = value.publicKeys.map((key: unknown) => { + if (typeof key !== 'string') throw new Error('Invalid public key') + return publicBlobKey(key) + }) + const expected = new X509Certificate(options.certificate).publicKey.export({ format: 'der', type: 'spki' }) + return keys.some(key => key.equals(expected)) ? 'matched' : 'certificate-mismatch' + } catch { + // Native failures may include container names or inherited diagnostics; expose only the incomplete result. + return 'unknown' + } +} + +/** + * Explain why public-data validation stopped signing without asserting device lock or authentication status. + * @param result Non-matching public-data result. + * @returns A credential-free operator diagnostic. + */ +export function windowsTokenIdentityFailure(result: Exclude): string { + const reasons = { + 'provider-unavailable': 'The configured eToken provider is unavailable; check the SafeNet driver.', + 'container-unavailable': 'The configured container could not be opened; after replacing the UKey, check its container name and visibility.', + 'certificate-mismatch': 'The returned container public keys do not match the configured certificate; check the certificate and container after replacing the UKey.', + unknown: 'The container public identity could not be confirmed; check device visibility and SafeNet public-information support.', + } + return `${reasons[result]} No PIN was supplied and no signing was attempted by this check. Signing stopped; device lock and PIN retry counts are unknown.` +} diff --git a/apps/desktop/scripts/windows-token-public-key.cs b/apps/desktop/scripts/windows-token-public-key.cs new file mode 100644 index 0000000000..2549138c0f --- /dev/null +++ b/apps/desktop/scripts/windows-token-public-key.cs @@ -0,0 +1,94 @@ +// Read only public material through the existing SafeNet CSP. No authentication or key mutation APIs. +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; + +public static class DshTokenPublicKey +{ + public const string Provider = "eToken Base Cryptographic Provider"; + public const uint PublicContextFlags = 0xF0000040; // CRYPT_VERIFYCONTEXT | CRYPT_SILENT + public const int NoKey = unchecked((int)0x8009000D); + + public interface IApi + { + int Acquire(string container, out IntPtr context); + int GetKey(IntPtr context, uint spec, out IntPtr key); + int Export(IntPtr key, byte[] bytes, ref uint length); + bool Destroy(IntPtr key); + bool Release(IntPtr context); + } + + public sealed class NativeApi : IApi + { + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)] + static extern bool CryptAcquireContextW(out IntPtr context, string container, string provider, uint type, uint flags); + [DllImport("advapi32.dll", SetLastError = true)] + static extern bool CryptGetUserKey(IntPtr context, uint spec, out IntPtr key); + [DllImport("advapi32.dll", SetLastError = true)] + static extern bool CryptExportKey(IntPtr key, IntPtr exportKey, uint type, uint flags, byte[] bytes, ref uint length); + [DllImport("advapi32.dll", SetLastError = true)] + static extern bool CryptDestroyKey(IntPtr key); + [DllImport("advapi32.dll", SetLastError = true)] + static extern bool CryptReleaseContext(IntPtr context, uint flags); + public int Acquire(string container, out IntPtr context) { + return CryptAcquireContextW(out context, container, Provider, 1, PublicContextFlags) ? 0 : Marshal.GetLastWin32Error(); + } + public int GetKey(IntPtr context, uint spec, out IntPtr key) { + return CryptGetUserKey(context, spec, out key) ? 0 : Marshal.GetLastWin32Error(); + } + public int Export(IntPtr key, byte[] bytes, ref uint length) { + return CryptExportKey(key, IntPtr.Zero, 6, 0, bytes, ref length) ? 0 : Marshal.GetLastWin32Error(); // PUBLICKEYBLOB + } + public bool Destroy(IntPtr key) { return CryptDestroyKey(key); } + public bool Release(IntPtr context) { return CryptReleaseContext(context, 0); } + } + + public sealed class Result + { + public string status = "unknown"; + public string[] publicKeys = new string[0]; + } + + // The adapter seam allows all failure paths to be exercised without loading a hardware provider. + public static Result Read(IApi api, string container) + { + var result = new Result(); + IntPtr context; + int error = api.Acquire(container, out context); + if (error != 0) { + if (error == unchecked((int)0x80090013) || error == unchecked((int)0x80090017) + || error == unchecked((int)0x8009001E)) result.status = "provider-unavailable"; + else if (error == unchecked((int)0x80090016)) result.status = "container-unavailable"; + return result; + } + try { + var keys = new List(); + foreach (uint spec in new uint[] { 2, 1 }) { + IntPtr key; + error = api.GetKey(context, spec, out key); + if (error == NoKey) continue; + if (error != 0) return result; + bool destroyed; + try { + uint length = 0; + if (api.Export(key, null, ref length) != 0 || length < 20 || length > 16384) return result; + var bytes = new byte[length]; + uint capacity = length; + if (api.Export(key, bytes, ref length) != 0 || length < 20 || length > capacity) return result; + keys.Add(Convert.ToBase64String(bytes, 0, (int)length)); + } finally { destroyed = api.Destroy(key); } + if (!destroyed) return result; + } + if (keys.Count > 0) { + result.publicKeys = keys.ToArray(); + result.status = "public-keys"; + } + } finally { + if (!api.Release(context)) { + result.status = "unknown"; + result.publicKeys = new string[0]; + } + } + return result; + } +} diff --git a/apps/desktop/scripts/windows-token-public-key.ps1 b/apps/desktop/scripts/windows-token-public-key.ps1 new file mode 100644 index 0000000000..9adb367c3b --- /dev/null +++ b/apps/desktop/scripts/windows-token-public-key.ps1 @@ -0,0 +1,4 @@ +# Only the container name is needed; the caller removes inherited signing credentials. +$ErrorActionPreference = 'Stop' +Add-Type -Path (Join-Path $PSScriptRoot 'windows-token-public-key.cs') +[DshTokenPublicKey]::Read([DshTokenPublicKey+NativeApi]::new(), $env:DSH_TOKEN_PUBLIC_CONTAINER) | ConvertTo-Json -Compress diff --git a/apps/desktop/tests/fixtures/windows-token-public-key-fake.cs b/apps/desktop/tests/fixtures/windows-token-public-key-fake.cs new file mode 100644 index 0000000000..9d2c1342f9 --- /dev/null +++ b/apps/desktop/tests/fixtures/windows-token-public-key-fake.cs @@ -0,0 +1,49 @@ + +public sealed class FakeTokenPublicKey : DshTokenPublicKey.IApi +{ + string scenario; + int releases, destroys, exports, keys; + public FakeTokenPublicKey(string value) { scenario = value; } + public int Acquire(string container, out System.IntPtr context) { + if (container != "fake-container") throw new System.Exception("unexpected container"); + context = new System.IntPtr(1); + if (scenario == "provider") return unchecked((int)0x80090013); + if (scenario == "container") return unchecked((int)0x80090016); + if (scenario == "auth") return unchecked((int)0x80090022); + return 0; + } + public int GetKey(System.IntPtr context, uint spec, out System.IntPtr key) { + keys++; + key = new System.IntPtr(2); + if (scenario == "no-key" || spec == 1) return DshTokenPublicKey.NoKey; + if (scenario == "get-key") return 5; + return 0; + } + public int Export(System.IntPtr key, byte[] bytes, ref uint length) { + exports++; + if (scenario == "export") return 5; + if (scenario == "size") { length = 1000000; return 0; } + length = 24; + return 0; + } + public bool Destroy(System.IntPtr key) { destroys++; return scenario != "destroy"; } + public bool Release(System.IntPtr context) { releases++; return scenario != "release"; } + public static string Test() { + if (DshTokenPublicKey.PublicContextFlags != 0xF0000040) throw new System.Exception("unsafe flags"); + foreach (string scenario in new [] { "ok", "provider", "container", "auth", "no-key", "get-key", "export", "size", "destroy", "release" }) { + var fake = new FakeTokenPublicKey(scenario); + var result = DshTokenPublicKey.Read(fake, "fake-container"); + string expected = scenario == "ok" ? "public-keys" : scenario == "provider" ? "provider-unavailable" + : scenario == "container" ? "container-unavailable" : "unknown"; + if (result.status != expected) throw new System.Exception("wrong outcome: " + scenario); + bool acquired = scenario != "provider" && scenario != "container" && scenario != "auth"; + if (fake.releases != (acquired ? 1 : 0)) throw new System.Exception("context leak: " + scenario); + bool hasKey = acquired && scenario != "no-key" && scenario != "get-key"; + if (fake.destroys != (hasKey ? 1 : 0)) throw new System.Exception("key leak: " + scenario); + if (!acquired && fake.keys != 0) throw new System.Exception("read after failed acquire"); + if (scenario == "ok" && (fake.exports != 2 || fake.keys != 2)) throw new System.Exception("incomplete read"); + if (result.status != "public-keys" && result.publicKeys.Length != 0) throw new System.Exception("partial result escaped"); + } + return "fake CSP cases passed"; + } +} diff --git a/apps/desktop/tests/windows-sign.spec.ts b/apps/desktop/tests/windows-sign.spec.ts index e5d5a7cd3f..3e0ec371a7 100644 --- a/apps/desktop/tests/windows-sign.spec.ts +++ b/apps/desktop/tests/windows-sign.spec.ts @@ -317,7 +317,9 @@ describe('Windows token signing', () => { 'C:\\release\\setup.exe', ['token-secret'], ) - expect(failure.message).toBe('Windows release signing failed for C:\\release\\setup.exe (exit 1): provider rejected ') + expect(failure.message).toContain('Windows release signing failed for C:\\release\\setup.exe (exit 1): provider rejected ') + expect(failure.message).toContain('After replacing the UKey') + expect(failure.message).toContain('does not establish device lock status') expect(failure.message).not.toContain('token-secret') expect(failure).not.toHaveProperty('cause') expect(failure).not.toHaveProperty('cmd') diff --git a/apps/desktop/tests/windows-signing-preflight.spec.ts b/apps/desktop/tests/windows-signing-preflight.spec.ts index e560654e80..008b9bfcc0 100644 --- a/apps/desktop/tests/windows-signing-preflight.spec.ts +++ b/apps/desktop/tests/windows-signing-preflight.spec.ts @@ -63,15 +63,32 @@ async function fixture() { sequence.push('compile'); await writeFile(output, 'inert unsigned probe') }) const sign = vi.fn(async () => { sequence.push('sign') }) + const inspectIdentity = vi.fn(async () => 'matched' as const) const inspect = vi.fn(async (_path: string) => { sequence.push('inspect') return sign.mock.calls.length === 0 ? { status: 'NotSigned', timestamped: false, thumbprint: null } : { status: 'Valid', timestamped: true, thumbprint: 'A'.repeat(40) } }) - return { root, compiler, sequence, runDirectory: run.directory, environment, stateDirectory: join(root, 'state'), compile, sign, inspect } + return { root, compiler, sequence, runDirectory: run.directory, environment, stateDirectory: join(root, 'state'), compile, sign, inspect, inspectIdentity } } +it.each(['provider-unavailable', 'container-unavailable', 'certificate-mismatch', 'unknown'] as const) +('stops before compilation and signing on public identity result %s', async (result) => { + const options = await fixture() + const inspectIdentity = vi.fn(async (_input: { environment: NodeJS.ProcessEnv }) => result) + await expect(preflightWindowsSigning({ ...options, inspectIdentity })).rejects.toThrow('No PIN was supplied') + expect(options.compile).not.toHaveBeenCalled() + expect(options.sign).not.toHaveBeenCalled() + expect(inspectIdentity).toHaveBeenCalledOnce() + expect(inspectIdentity.mock.calls[0]![0].environment).not.toHaveProperty('DSH_DESKTOP_WINDOWS_TOKEN_PIN') + expect(inspectIdentity.mock.calls[0]![0].environment).not.toHaveProperty('DSH_DESKTOP_WINDOWS_SIGNTOOL') + const events = await readFile(join(options.runDirectory, 'events.jsonl'), 'utf8') + expect(events).toContain(`"result":"${result}"`) + expect(events).not.toContain('fixture-pin') + expect(events).not.toContain('fixture-container') +}) + it('compiles one new probe and verifies one production-format signature before success', async () => { const options = await fixture() await preflightWindowsSigning(options) diff --git a/apps/desktop/tests/windows-token-identity.spec.ts b/apps/desktop/tests/windows-token-identity.spec.ts new file mode 100644 index 0000000000..d184c23f6b --- /dev/null +++ b/apps/desktop/tests/windows-token-identity.spec.ts @@ -0,0 +1,101 @@ +import { execFileSync } from 'node:child_process' +import { X509Certificate } from 'node:crypto' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { rootCertificates } from 'node:tls' +import { expect, it, vi } from 'vitest' +import { inspectWindowsTokenIdentity } from '../scripts/windows-token-identity.ts' + +const launch = vi.hoisted(() => vi.fn(async (_file: string, _args: string[], _options: { env: NodeJS.ProcessEnv; timeout: number }) => ({ stdout: '', stderr: '' }))) +vi.mock('node:child_process', async (importOriginal) => { + const original = await importOriginal() + const { promisify } = await import('node:util') + return { ...original, execFile: Object.assign(vi.fn(), { [promisify.custom]: launch }) } +}) + +const certificate = new X509Certificate(rootCertificates.find(pem => new X509Certificate(pem).publicKey.asymmetricKeyType === 'rsa')!) +const jwk = certificate.publicKey.export({ format: 'jwk' }) +const modulus = Buffer.from(jwk.n!, 'base64url') +const exponent = Buffer.from(jwk.e!, 'base64url').reduce((value, byte) => value * 256 + byte, 0) +const blob = Buffer.alloc(20 + modulus.length) +blob[0] = 6; blob[1] = 2 +blob.writeUInt32LE(0x2400, 4); blob.writeUInt32LE(0x31415352, 8) +blob.writeUInt32LE(modulus.length * 8, 12); blob.writeUInt32LE(exponent, 16) +Buffer.from(modulus).reverse().copy(blob, 20) +const input = { certificate: certificate.raw, container: 'fixture-container', environment: {} } + +it('compares the returned public key with the certificate, not the container name', async () => { + const query = vi.fn(async () => JSON.stringify({ status: 'public-keys', publicKeys: [blob.toString('base64')] })) + expect(await inspectWindowsTokenIdentity({ ...input, query })).toBe('matched') + expect(query).toHaveBeenCalledOnce() + const other = Buffer.from(blob) + other[20] = other[20]! ^ 2 + expect(await inspectWindowsTokenIdentity({ ...input, query: async () => JSON.stringify({ + status: 'public-keys', publicKeys: [other.toString('base64')], + }) })).toBe('certificate-mismatch') +}) + +it.each(['provider-unavailable', 'container-unavailable', 'unknown'])('preserves public query result %s', async (status) => { + expect(await inspectWindowsTokenIdentity({ ...input, query: async () => JSON.stringify({ status }) })).toBe(status) +}) + +it.each(['not json', '{}', '{"status":"public-keys","publicKeys":[]}', + '{"status":"public-keys","publicKeys":["bad"]}', '{"status":"matched"}', + JSON.stringify({ status: 'public-keys', publicKeys: [blob.toString('base64'), 'bad'] })]) +('rejects incomplete or malformed public data without accepting a partial match: %s', async (output) => { + expect(await inspectWindowsTokenIdentity({ ...input, query: async () => output })).toBe('unknown') +}) + +it('contains query failures without retrying or exposing diagnostics', async () => { + const query = vi.fn(async () => { throw new Error('private fixture diagnostic') }) + expect(await inspectWindowsTokenIdentity({ ...input, query })).toBe('unknown') + expect(query).toHaveBeenCalledOnce() +}) + +it('launches only the public query with no PIN, signing tool or inherited secrets', async () => { + launch.mockClear() + launch.mockResolvedValueOnce({ stdout: JSON.stringify({ status: 'public-keys', publicKeys: [blob.toString('base64')] }), stderr: '' }) + expect(await inspectWindowsTokenIdentity({ ...input, environment: { + PATH: 'fixture-path', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'must-not-pass', API_KEY: 'must-not-pass', + DSH_DESKTOP_WINDOWS_SIGNTOOL: 'must-not-run', NODE_OPTIONS: 'must-not-pass', NODE_PATH: 'must-not-pass', + } })).toBe('matched') + expect(launch).toHaveBeenCalledOnce() + const [file, args, options] = launch.mock.calls[0]! + expect(file).toBe('pwsh') + expect(args).toContain('-NonInteractive') + expect(args.at(-1)).toMatch(/windows-token-public-key\.ps1$/u) + expect(options.timeout).toBe(15_000) + expect(options.env).toEqual({ PATH: 'fixture-path', DSH_TOKEN_PUBLIC_CONTAINER: 'fixture-container' }) +}) + +it('reports a helper timeout or stderr as unknown without retry', async () => { + launch.mockClear() + launch.mockRejectedValueOnce(new Error('timeout with private diagnostic')) + expect(await inspectWindowsTokenIdentity(input)).toBe('unknown') + expect(launch).toHaveBeenCalledOnce() + launch.mockResolvedValueOnce({ stdout: JSON.stringify({ status: 'public-keys', publicKeys: [blob.toString('base64')] }), stderr: 'unexpected' }) + expect(await inspectWindowsTokenIdentity(input)).toBe('unknown') +}) + +it.skipIf(process.platform !== 'win32')('exercises native control flow with a fake CSP and never constructs the native adapter', () => { + const root = mkdtempSync(join(tmpdir(), 'token-public-test-')) + try { + const source = resolve(import.meta.dirname, '../scripts/windows-token-public-key.cs') + const runner = join(root, 'test.ps1') + const fixture = resolve(import.meta.dirname, 'fixtures/windows-token-public-key-fake.cs') + writeFileSync(runner, `param([string]$Source, [string]$Fixture) +$ErrorActionPreference = 'Stop' +Add-Type -TypeDefinition ((Get-Content -LiteralPath $Source -Raw) + (Get-Content -LiteralPath $Fixture -Raw)) +[FakeTokenPublicKey]::Test() +`) + expect(execFileSync('pwsh', ['-NoProfile', '-NonInteractive', '-File', runner, source, fixture], { encoding: 'utf8', timeout: 30_000 })) + .toContain('fake CSP cases passed') + const native = readFileSync(source, 'utf8') + const provider = /Provider = "([^"]+)"/u.exec(native)![1] + expect(readFileSync(resolve(import.meta.dirname, '../scripts/windows-sign.cmd'), 'utf8')).toContain(`/csp "${provider}"`) + expect(native).toContain('CryptAcquireContextW(out context, container, Provider, 1, PublicContextFlags)') + expect(native).toContain('CryptExportKey(key, IntPtr.Zero, 6, 0, bytes, ref length)') + expect(native).not.toMatch(/CryptSign|CryptSetProvParam|CryptGenKey|CryptAcquireCertificatePrivateKey/u) + } finally { rmSync(root, { recursive: true, force: true, maxRetries: 3 }) } +}) diff --git a/apps/desktop/windows-signing/README.i18n.yaml b/apps/desktop/windows-signing/README.i18n.yaml index 7a8c03034f..e1bbf64fb5 100644 --- a/apps/desktop/windows-signing/README.i18n.yaml +++ b/apps/desktop/windows-signing/README.i18n.yaml @@ -27,8 +27,8 @@ en: 4b5548818375eb9a zh: 8c975adbb1dca9c8 /windows-signing-and-runner-operations/signing-and-timestamp-completion: - en: e42565e00283e3f6 - zh: 584a33f07b50fd5b + en: 74003162e4c21446 + zh: a8392e981eaec99c /windows-signing-and-runner-operations/failure-records-and-operator-recovery: en: 5401b605a2f3a44e zh: fc1f6ee667bcd20d diff --git a/apps/desktop/windows-signing/README.md b/apps/desktop/windows-signing/README.md index 10fb15194f..b0550d296e 100644 --- a/apps/desktop/windows-signing/README.md +++ b/apps/desktop/windows-signing/README.md @@ -66,6 +66,8 @@ Follow the [version and build instructions](../packaging/README.md#release-versi Signed Windows builds run a supervised signing preflight before compilation or dependency preparation. Static configuration, certificate validity, audit storage, compiler availability and any retained signing interlock are checked without token access. The local .NET Framework C# compiler creates a small private probe; the production signer signs it once, and verification requires the configured certificate and a timestamp before building continues. The probe is never executed. The overall 60-second preflight deadline includes timestamp attempts; deadline expiry, hardware signing errors and verification failures stop the run without another hardware attempt. Success proves the current signing path works, not that the PIN was independently authenticated: SafeNet may reuse login state. Do not log out or repeat authentication to test the PIN. `--check`, preparation-only and `--unsigned` modes do not run this hardware preflight; unsigned artifacts remain ineligible for release upload. Automated regression tests use a fake signer; release operators qualify real hardware separately. +Before compiling the probe, a separate noninteractive process reads public keys from the fixed eToken CSP using `CRYPT_VERIFYCONTEXT | CRYPT_SILENT`. It receives the container name but no PIN or signing credentials, and has a 15-second deadline within the existing preflight deadline. Both RSA key slots must be read completely (an absent slot is allowed); at least one public key must match the configured certificate. Provider/container access failure, unsupported public reads, malformed output, timeout or mismatch stops before hardware signing. A container-access error does not establish that the device or container is absent. Public information can be cached by middleware: a match does not authenticate the device, validate the PIN or establish remaining retries. This path is covered by a simulated CSP; the installed SafeNet version still requires separate operator qualification. No failure automatically retries authentication or clears the existing interlock. + Insert and unlock the token before packaging. The electron-builder hook passes each artifact to the CRLF `scripts/windows-sign.cmd`, which invokes the configured SignTool once with `/f`, SafeNet `/kc "[{{PIN}}]=container"`, `/csp "eToken Base Cryptographic Provider"`, a SHA-256 file digest, without a timestamp. The hook then completes a DigiCert SHA-256 RFC 3161 timestamp on isolated copies without signing credentials. The hook never substitutes electron-builder's bundled SignTool and never retries a failed signing request. Windows release packaging fails instead of emitting unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. Timestamp completion retries only a normally exited timestamp command returning failure or warning, at most three times with one- and two-second delays. Each attempt starts from the same verified primary signature. Launch errors, uncertain termination and verification failures stop immediately. SignTool operates on short private paths; publication copies verified bytes to the target volume before atomic replacement. Final Windows trust, certificate, timestamp and normalized full-file equality are required. Exhaustion stops packaging and preserves evidence without another hardware call. See the [signature completion decision](../../../.agents/notes/implemented/process/2026-09-17-windows-signature-completion.md). diff --git a/apps/desktop/windows-signing/README.zh.md b/apps/desktop/windows-signing/README.zh.md index df756d7d1e..66c7d0fa92 100644 --- a/apps/desktop/windows-signing/README.zh.md +++ b/apps/desktop/windows-signing/README.zh.md @@ -66,6 +66,8 @@ PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet Windows 签名构建在编译或准备依赖前执行受监督的签名预检。静态配置、证书有效期、审计存储、编译器可用性及遗留签名锁的检查不访问 Token。本地 .NET Framework C# 编译器生成一个专用小探针,由正式签名器仅签名一次,随后必须验出配置的证书和时间戳才能继续构建。探针绝不执行。预检的整体 60 秒期限包含时间戳尝试;超时、硬件签名报错或验签失败都会停止本轮流程,不再次调用硬件。成功只证明当前签名路径可用,不证明 PIN 已独立认证:SafeNet 可能复用登录状态。不要为了验证 PIN 而注销或重复认证。`--check`、仅准备和 `--unsigned` 模式不执行此硬件预检;未签名产物仍不能发布上传。自动回归测试使用假签名器,真实硬件由发布操作人员单独验收。 +编译探针前,独立非交互进程使用 `CRYPT_VERIFYCONTEXT | CRYPT_SILENT` 从固定的 eToken CSP 读取公钥。该进程只接收容器名,不接收 PIN 或签名凭据;15 秒期限计入已有预检总期限。两个 RSA 密钥槽必须完整读取(允许槽不存在),且至少一个公钥与配置证书匹配。提供程序或容器访问失败、不支持公开读取、输出损坏、超时或不匹配时,在硬件签名前停止。容器访问错误不能证明设备或容器不存在。中间件可能缓存公开信息,因此匹配不代表设备已认证、PIN 正确或已确认剩余尝试次数。此路径已由模拟 CSP 覆盖,当前 SafeNet 版本仍需操作人员单独验收。任何失败都不会自动重试认证或清除已有保护锁。 + 打包前插入并解锁 Token。electron-builder 钩子把每个产物交给采用 CRLF 的 `scripts/windows-sign.cmd`;该 CMD 只调用一次已配置的 SignTool,并指定 `/f`、SafeNet `/kc "[{{PIN}}]=容器"`、`/csp "eToken Base Cryptographic Provider"`和 SHA-256 文件摘要,不请求时间戳。随后钩子在隔离副本上完成 DigiCert SHA-256 RFC 3161 时间戳,不传递签名凭据。钩子不会改用 electron-builder 内置的 SignTool,也不会重试失败的签名请求。SignTool、证书、容器、PIN、Token 或签名不可用时,Windows 发布打包会失败,不会生成未签名产物。 时间戳处理仅对正常退出但返回失败或警告的时间戳命令重试,最多尝试三次,间隔为一秒和两秒。每次均从同一份已验证的主签名开始。启动错误、终止状态不确定或验签失败会立即停止。SignTool 使用短的私有路径;发布时先把已验证字节复制到目标卷,再原子替换。最终必须通过 Windows 信任、证书、时间戳和规范化全文件相等检查。尝试耗尽后停止打包并保留证据,不再次调用硬件。参见[签名完成决策](../../../.agents/notes/implemented/process/2026-09-17-windows-signature-completion.zh.md)。 From 0f21742b257f5bdee7ac9b3d6c8418e65a4b7767 Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:51:49 +0800 Subject: [PATCH 24/46] fix(macos): apply ZIP compression when skipping notarization --- apps/desktop/packaging/README.i18n.yaml | 4 +- apps/desktop/packaging/README.md | 2 +- apps/desktop/packaging/README.zh.md | 2 +- apps/desktop/scripts/package-target.ts | 18 ++++++-- .../tests/package-target-stages.spec.ts | 41 ++++++++++++++++++- 5 files changed, 58 insertions(+), 9 deletions(-) diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index 3a2f5aad1a..2d47bde630 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -27,8 +27,8 @@ en: cb554f51438381b1 zh: f7a40d65fc5a57fc /desktop-packaging/macos-self-hosted-packaging: - en: 734d1df945da8f45 - zh: 2b17a3039efdde78 + en: f4668c5d616ba6c9 + zh: 2416d68e55ed48c2 /desktop-packaging/unsigned-windows-test-installer: en: 6843a0fdc03ead58 zh: 957f92fc8126e23e diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index 3277ee277c..a31c55cef8 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -134,7 +134,7 @@ Run `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/dsh-ci"` to prep Run `bash apps/desktop/scripts/start-macos-packaging-runner.sh "$HOME/dsh-ci/runner" "$HOME/dsh-ci/packaging" --check` to check the host tools and configuration files. The script selects the active Xcode and its macOS SDK together and compiles a small linker probe. After registration, omit `--check` to run in the foreground with a cleared inherited environment and prevent idle sleep while the runner is alive. Stop it with Ctrl-C before installing the service below. The runner owns its `_work` checkout, separate from daily development. The account can read its signing files, so run only trusted code. The existing notarization proxy restoration rules apply; after forced termination, inspect retained recovery records before another build. New setup templates set `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` to the absolute `cache/macos-signatures` directory outside checkout. For existing installations, rerun setup and add that field to each preset; existing files are preserved. This retains signatures across clean checkouts; cache verification and limits are described under [macOS signing](#macos-release-signing). -The `skip_notarization` workflow option defaults to `false`; the equivalent CLI flag is `--skip-notarization`. It retains Developer ID signing, DMG signature verification and packaged-runtime verification, but omits Apple notarization and stapling and does not require Apple notarization credentials. DMG/ZIP filenames carry `-unnotarized`, are stored under `.desktop-build/targets//unnotarized-artifacts/`, and are uploaded with an `unnotarized-debug` label. These artifacts are for internal debugging only: Gatekeeper may block downloaded copies, and they are not qualified for distribution or update publishing. No release completion record is written. The flag rejects unsigned, Windows and preparation-only builds. +ZIP compression uses the same configured level with or without notarization; the App is assembled once, then DMG and ZIP are built separately so the ZIP setting does not change DMG compression. The `skip_notarization` workflow option defaults to `false`; the equivalent CLI flag is `--skip-notarization`. It retains Developer ID signing, DMG signature verification and packaged-runtime verification, but omits Apple notarization and stapling and does not require Apple notarization credentials. DMG/ZIP filenames carry `-unnotarized`, are stored under `.desktop-build/targets//unnotarized-artifacts/`, and are uploaded with an `unnotarized-debug` label. These artifacts are for internal debugging only: Gatekeeper may block downloaded copies, and they are not qualified for distribution or update publishing. No release completion record is written. The flag rejects unsigned, Windows and preparation-only builds. Install the user service from the checkout as the same account that registered the runner, without `sudo`: diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index ce70ef9ec6..86158c006e 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -134,7 +134,7 @@ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-s 运行 `bash apps/desktop/scripts/start-macos-packaging-runner.sh "$HOME/dsh-ci/runner" "$HOME/dsh-ci/packaging" --check` 检查宿主工具和配置文件。脚本同时选择当前 Xcode 及其 macOS SDK,并编译小型链接探针。注册后省略 `--check`,清理继承环境并在前台启动 runner,存活期间阻止空闲睡眠。安装下方服务前,先用 Ctrl-C 停止。runner 拥有 `_work` 下的源码目录,与日常开发分开。该账户可读取签名文件,因此只执行可信代码。沿用现有公证代理恢复规则;强制终止后,先检查保留的恢复记录再构建。新建模板将 `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` 设为 checkout 外 `cache/macos-signatures` 的绝对路径。已有部署重新运行 setup 后,在各 preset 中补上该字段;已有文件保持不变。这样可跨干净检出保留签名;缓存验证和容量限制见 [macOS 签名](#macos-release-signing)。 -工作流选项 `skip_notarization` 默认为 `false`;对应 CLI 参数是 `--skip-notarization`。该模式保留 Developer ID 签名、DMG 签名验证和打包运行时验证,跳过 Apple 公证和 stapling,不要求 Apple 公证凭据。DMG/ZIP 文件名带 `-unnotarized`,存放于 `.desktop-build/targets//unnotarized-artifacts/`,上传产物标为 `unnotarized-debug`。这些产物仅供内部调试:Gatekeeper 可能阻止下载副本运行,不能视为可分发包或用于更新发布。该模式不写入发布完成记录,不能与未签名、Windows 或仅准备构建组合。 +无论是否公证,ZIP 都使用同一压缩配置;App 只组装一次,再分别生成 DMG 和 ZIP,确保 ZIP 设置不改变 DMG 压缩。工作流选项 `skip_notarization` 默认为 `false`;对应 CLI 参数是 `--skip-notarization`。该模式保留 Developer ID 签名、DMG 签名验证和打包运行时验证,跳过 Apple 公证和 stapling,不要求 Apple 公证凭据。DMG/ZIP 文件名带 `-unnotarized`,存放于 `.desktop-build/targets//unnotarized-artifacts/`,上传产物标为 `unnotarized-debug`。这些产物仅供内部调试:Gatekeeper 可能阻止下载副本运行,不能视为可分发包或用于更新发布。该模式不写入发布完成记录,不能与未签名、Windows 或仅准备构建组合。 在 checkout 中,以注册 runner 的同一账户安装用户服务,不使用 `sudo`: diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index c72bcc71f4..b6bc6efb63 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -525,6 +525,10 @@ export async function packageTarget( await execute(['run', 'prepare:dsh', ...(signPrimaryRuntime ? ['--defer-runtime-smoke'] : [])], downloadEnv) if (signPrimaryRuntime) await execute(['run', 'sign:primary-runtime', '--dsh'], electronBuilderEnv) if (invocation.prepareOnly) return + const buildMacArtifact = (artifact: DesktopPrepackagedArtifact) => execute( + desktopElectronBuilderArguments(target, false, artifact), artifact.format === 'zip' + ? { ...electronBuilderEnv, ELECTRON_BUILDER_COMPRESSION_LEVEL: String(mac?.zipCompressionLevel) } + : electronBuilderEnv) if (target.platform === 'darwin' && !invocation.unsigned && !invocation.skipNotarization && !invocation.directory) { await execute([ ...desktopElectronBuilderArguments(target, true), @@ -537,9 +541,7 @@ export async function packageTarget( version: resolveDesktopBuildVersion(environment, packageVersion(join(APP_ROOT, 'package.json'), 'desktop package')), artifactsRoot: buildPaths.artifacts, environment: electronBuilderEnv, - }, artifact => execute(desktopElectronBuilderArguments(target, false, artifact), artifact.format === 'zip' - ? { ...electronBuilderEnv, ELECTRON_BUILDER_COMPRESSION_LEVEL: String(mac?.zipCompressionLevel) } - : electronBuilderEnv)), undefined, undefined, proxyEvent) + }, buildMacArtifact), undefined, undefined, proxyEvent) } else if (target.platform === 'darwin' && !invocation.unsigned && !invocation.skipNotarization) { await execute([...desktopElectronBuilderArguments(target, true), '--config.mac.notarize=false'], electronBuilderEnv) await execute(['exec', 'tsx', 'scripts/smoke-packaged-runtime.ts'], targetEnv) @@ -547,7 +549,15 @@ export async function packageTarget( await withMacOSNotarizationProxy(mac?.notarizationProxy, () => notarizeMacOS({ appPath, ...resolveMacOSNotarizationEnvironment(environment) }), undefined, undefined, proxyEvent) } else { - await signedStage('artifacts', () => execute(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv)) + if (target.platform === 'darwin' && invocation.skipNotarization && !invocation.directory) { + await execute(desktopElectronBuilderArguments(target, true), electronBuilderEnv) + const appPath = join(artifactsRoot, target.arch === 'arm64' ? 'mac-arm64' : 'mac', 'DeepSeek Harness.app') + for (const format of ['dmg', 'zip'] as const) { + await buildMacArtifact({ format, appPath, output: artifactsRoot }) + } + } else { + await signedStage('artifacts', () => execute(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv)) + } await execute(['exec', 'tsx', 'scripts/smoke-packaged-runtime.ts', ...(invocation.unsigned ? ['--unsigned'] : []), ...(invocation.skipNotarization ? ['--skip-notarization'] : [])], targetEnv) } diff --git a/apps/desktop/tests/package-target-stages.spec.ts b/apps/desktop/tests/package-target-stages.spec.ts index b281dec23e..37bd38384f 100644 --- a/apps/desktop/tests/package-target-stages.spec.ts +++ b/apps/desktop/tests/package-target-stages.spec.ts @@ -349,7 +349,8 @@ it.each([false, true])('keeps signing and runtime verification when notarization await packageTarget(parseDesktopPackageInvocation(['mac-arm64', '--skip-notarization', ...(directory ? ['--dir'] : [])], 'darwin', 'arm64'), { ...environment, CSC_LINK: 'signing.p12', CSC_KEY_PASSWORD: 'fixture-password', }, run) - expect(stages.at(-2)).toBe(`exec electron-builder --config electron-builder.config.mjs --mac --arm64 --publish never${directory ? ' --dir' : ''}`) + expect(stages).toContain('exec electron-builder --config electron-builder.config.mjs --mac --arm64 --publish never --dir') + if (!directory) expect(stages.at(-2)).toContain('--mac zip') expect(stages.at(-1)).toBe('exec tsx scripts/smoke-packaged-runtime.ts --skip-notarization') expect(run.run.mock.calls.at(-2)![3].env).toMatchObject({ DSH_DESKTOP_UNSIGNED: '0', DSH_DESKTOP_MACOS_SKIP_NOTARIZATION: '1', CSC_LINK: 'signing.p12', @@ -359,6 +360,34 @@ it.each([false, true])('keeps signing and runtime verification when notarization expect(writeFileSync).not.toHaveBeenCalled() }) +it.each(['mac-arm64', 'mac-x64'] as const)('applies ZIP compression without changing DMG settings when skipping notarization for %s', async (target) => { + for (const level of [undefined, '3', '7']) { + const { run } = supervisor() + await packageTarget(parseDesktopPackageInvocation([target, '--skip-notarization'], 'darwin', 'arm64'), { + ...environment, ELECTRON_BUILDER_COMPRESSION_LEVEL: '9', + ...(level === undefined ? {} : { DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL: level }), + }, run) + const builders = run.run.mock.calls.filter(call => call[0].startsWith('exec electron-builder')) + expect(builders).toHaveLength(3) + const [app, dmg, zip] = builders + expect(app![0]).toContain('--dir') + expect(dmg![0]).toContain('--mac dmg') + expect(zip![0]).toContain('--mac zip') + expect(app![3].env.ELECTRON_BUILDER_COMPRESSION_LEVEL).toBe('9') + expect(dmg![3].env.ELECTRON_BUILDER_COMPRESSION_LEVEL).toBe('9') + expect(zip![3].env.ELECTRON_BUILDER_COMPRESSION_LEVEL).toBe(level ?? '3') + for (const call of [dmg!, zip!]) { + expect(call[0]).toContain('--prepackaged') + expect(call[0]).toContain('unnotarized-artifacts') + expect(call[3].env.DSH_DESKTOP_MACOS_SKIP_NOTARIZATION).toBe('1') + expect(call[3].env.DSH_DESKTOP_UNSIGNED).toBe('0') + } + } + expect(packageMacOSArtifacts).not.toHaveBeenCalled() + expect(withMacOSNotarizationProxy).not.toHaveBeenCalled() + expect(writeFileSync).not.toHaveBeenCalled() +}) + it('fails debugging packages when runtime verification fails', async () => { const failure = 'exec tsx scripts/smoke-packaged-runtime.ts --skip-notarization' const { run, stages } = supervisor(failure) @@ -368,6 +397,16 @@ it('fails debugging packages when runtime verification fails', async () => { expect(writeFileSync).not.toHaveBeenCalled() }) +it('stops unnotarized ZIP packaging when the DMG stage fails', async () => { + const failure = 'exec electron-builder --config electron-builder.config.mjs --mac dmg' + const { run, stages } = supervisor(failure) + await expect(packageTarget(parseDesktopPackageInvocation(['mac-arm64', '--skip-notarization'], 'darwin', 'arm64'), environment, run)) + .rejects.toThrow('stage refused') + expect(stages.some(stage => stage.includes('--mac zip'))).toBe(false) + expect(stages.some(stage => stage.includes('smoke-packaged-runtime'))).toBe(false) + expect(writeFileSync).not.toHaveBeenCalled() +}) + it.each([undefined, '7'])('applies file-owned compression only to the final notarized ZIP (%s)', async (level) => { const { run } = supervisor() vi.mocked(packageMacOSArtifacts).mockImplementationOnce(async (_request, build) => { From 4db4345865656c8844a6eb000392b207e646e86c Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:27:28 +0800 Subject: [PATCH 25/46] test(windows): compare signing concurrency with supervised probes --- .../helpers/windows-signing-experiment.ts | 70 +++++ .../tests/windows-signing-concurrency.perf.ts | 180 ++++++++++++ .../tests/windows-signing-concurrency.spec.ts | 178 ++++++++++++ .../tests/windows-signing-experiment.spec.ts | 258 ++++++++++++++++++ apps/desktop/windows-signing/README.i18n.yaml | 4 +- apps/desktop/windows-signing/README.md | 2 + apps/desktop/windows-signing/README.zh.md | 2 + 7 files changed, 692 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/tests/helpers/windows-signing-experiment.ts create mode 100644 apps/desktop/tests/windows-signing-concurrency.perf.ts create mode 100644 apps/desktop/tests/windows-signing-concurrency.spec.ts create mode 100644 apps/desktop/tests/windows-signing-experiment.spec.ts diff --git a/apps/desktop/tests/helpers/windows-signing-experiment.ts b/apps/desktop/tests/helpers/windows-signing-experiment.ts new file mode 100644 index 0000000000..b85056e7bb --- /dev/null +++ b/apps/desktop/tests/helpers/windows-signing-experiment.ts @@ -0,0 +1,70 @@ +/** Bounded scheduling for the opt-in signing experiment; production signing does not use this helper. */ + +/** Operations owned by one experiment, with independently bounded completion and hardware work. */ +export interface SigningExperimentOptions { + readonly jobs: 1 | 2 + readonly hardwareConcurrency: 1 | 2 + readonly signal?: AbortSignal + /** Complete one item, awaiting the supplied signing operation and all subsequent work before returning. */ + readonly complete: (item: T, sign: (path: string) => Promise) => Promise + /** Perform one hardware request without retrying it. */ + readonly sign: (item: T, path: string) => Promise +} + +/** + * Schedule independent experiment items and stop dispatch on the first failure or cancellation. + * @param items Ordered inputs owned by this batch. + * @param options Completion workers, hardware limit, cancellation and experiment operations. + * @returns Resolves after every item completes; otherwise drains issued work before rejecting with the first reason. + * Cancellation prevents new items and hardware requests but does not terminate an issued native operation. + */ +export async function runSigningExperimentBatch(items: readonly T[], options: SigningExperimentOptions): Promise { + let failure: { reason: unknown } | undefined + let next = 0 + let signing = 0 + const requests = new Set>() + const waiting = new Set<() => void>() + const wake = (): void => { + for (const resume of waiting) resume() + waiting.clear() + } + const stop = (reason: unknown): void => { + failure ??= { reason } + wake() + } + const aborted = (): void => { stop(options.signal?.reason) } + const signItem = async (item: T, path: string): Promise => { + while (failure === undefined && signing >= options.hardwareConcurrency) { + await new Promise((resume) => { waiting.add(resume) }) + } + if (failure !== undefined) throw failure.reason + signing++ + try { await options.sign(item, path) } + catch (error) { stop(error); throw error } + finally { signing--; wake() } + } + const worker = async (): Promise => { + while (failure === undefined && next < items.length) { + const item = items[next++]! + try { + await options.complete(item, (path) => { + const request = signItem(item, path) + requests.add(request) + void request.then(() => requests.delete(request), () => requests.delete(request)) + return request + }) + } + catch (error) { stop(error) } + } + } + options.signal?.addEventListener('abort', aborted, { once: true }) + try { + if (options.signal?.aborted) aborted() + await Promise.all(Array.from({ length: Math.min(options.jobs, items.length) }, worker)) + // A completion failure can leave a previously issued request awaiting its native result. + await Promise.allSettled(requests) + if (failure !== undefined) throw failure.reason + } finally { + options.signal?.removeEventListener('abort', aborted) + } +} diff --git a/apps/desktop/tests/windows-signing-concurrency.perf.ts b/apps/desktop/tests/windows-signing-concurrency.perf.ts new file mode 100644 index 0000000000..ad135c92ae --- /dev/null +++ b/apps/desktop/tests/windows-signing-concurrency.perf.ts @@ -0,0 +1,180 @@ +/** Operator-only comparison of serial signing, post-sign overlap and two concurrent hardware requests. */ +import { execFile } from 'node:child_process' +import { createHash, X509Certificate } from 'node:crypto' +import { existsSync } from 'node:fs' +import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises' +import { homedir, cpus, totalmem } from 'node:os' +import { isAbsolute, join, resolve } from 'node:path' +import { parseArgs, promisify } from 'node:util' +import { fileURLToPath } from 'node:url' +import { loadDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs' +import { createPackagingRun, recordPackagingEvent } from '../scripts/packaging-run.mjs' +import { beginWindowsSigningAttempt } from '../scripts/windows-signing-state.mjs' +import { withWindowsSigningStage } from '../scripts/windows-signing-stage.mjs' +import { inspectWindowsTokenIdentity, windowsTokenIdentityFailure } from '../scripts/windows-token-identity.ts' +import { buildWindowsSigningEnvironment, createWindowsTokenSigner, scrubWindowsSigningEnvironment } from '../scripts/windows-sign.mjs' +import { inspectWindowsRuntimeSignature } from '../scripts/windows-runtime-signature.mjs' +import { completeWindowsSignature, normalizeWindowsSignature } from '../scripts/windows-timestamp.mjs' +import { runSigningExperimentBatch } from './helpers/windows-signing-experiment.ts' + +const APP_ROOT = fileURLToPath(new URL('..', import.meta.url)) +const MODES = [ + { name: 'serial', jobs: 1, hardwareConcurrency: 1 }, + { name: 'pipeline', jobs: 2, hardwareConcurrency: 1 }, + { name: 'hardware2', jobs: 2, hardwareConcurrency: 2 }, +] as const + +/** + * Run an explicitly opted-in hardware experiment, retaining all generated probes and audit records. + * @param args CLI arguments; omission uses the current process arguments. + * @returns Resolves after all requested batches verify; any failure retains the batch interlock and stops later modes. + */ +export async function runWindowsSigningConcurrencyExperiment(args = process.argv.slice(2)): Promise { + const { values } = parseArgs({ args, options: { + execute: { type: 'boolean' }, 'config-root': { type: 'string' }, rounds: { type: 'string', default: '2' }, + } }) + if (!values.execute) { + console.log('Operator hardware experiment: node apps/desktop/tests/windows-signing-concurrency.perf.ts --execute --config-root [--rounds 1|2]') + return + } + if (process.platform !== 'win32' || !values['config-root'] || !isAbsolute(values['config-root']) + || !['1', '2'].includes(values.rounds)) throw new Error('Requires Windows, an absolute config-root and rounds 1 or 2') + const environment = loadDesktopPackageEnvironment('win32', { + ...process.env, DSH_PACKAGING_PRESET: 'test', DSH_PACKAGING_CONFIG_DIR: values['config-root'], + }) + const certificateFile = environment.DSH_DESKTOP_WINDOWS_CER_FILE! + const signTool = environment.DSH_DESKTOP_WINDOWS_SIGNTOOL! + const tokenPin = environment.DSH_DESKTOP_WINDOWS_TOKEN_PIN! + const keyContainer = environment.DSH_DESKTOP_WINDOWS_KEY_CONTAINER!.trim() + // Construction validates the same signing inputs; the production serial hook is never invoked here. + createWindowsTokenSigner({ certificateFile, signTool, tokenPin, keyContainer }) + const certificate = await readFile(certificateFile) + const leaf = new X509Certificate(certificate) + if (Date.now() < Date.parse(leaf.validFrom) || Date.now() > Date.parse(leaf.validTo)) { + throw new Error('Signing experiment requires a currently valid public certificate') + } + const thumbprint = leaf.fingerprint.replaceAll(':', '') + const safeEnvironment = scrubWindowsSigningEnvironment(environment) + delete safeEnvironment.NODE_OPTIONS + delete safeEnvironment.NODE_PATH + const run = createPackagingRun(join(APP_ROOT, '.desktop-build', 'qualification', 'signing-concurrency'), { + kind: 'operator-signing-concurrency', certificateSha256: createHash('sha256').update(certificate).digest('hex'), + node: process.version, cpu: cpus()[0]?.model, logicalCpus: cpus().length, memoryBytes: totalmem(), + rounds: Number(values.rounds), resourceBytes: [0, 1024 * 1024, 16 * 1024 * 1024], filesPerBatch: 6, + cache: 'disabled', probeExecuted: false, + }, { parallel: true, secrets: [tokenPin, keyContainer] }) + console.log(`SIGNING_EXPERIMENT_RECORD ${run.directory}`) + const controller = new AbortController() + const abort = (): void => { controller.abort(new Error('Operator interrupted the signing experiment')) } + process.once('SIGINT', abort) + process.once('SIGTERM', abort) + const record = (event: object): void => { recordPackagingEvent(run.directory, event) } + let success = false + try { + await withWindowsSigningStage({ stage: 'operator-signing-concurrency', signal: controller.signal, record }, async () => { + if (existsSync(join(homedir(), '.dsh-desktop-signing', 'attempt.json'))) { + throw new Error('Retained signing interlock requires operator inspection; experiment refused') + } + const identity = await inspectWindowsTokenIdentity({ certificate, container: keyContainer, environment: safeEnvironment }) + record({ type: 'experiment-public-identity', result: identity, pinSupplied: false }) + if (identity !== 'matched') throw new Error(windowsTokenIdentityFailure(identity)) + const templates: string[] = [] + const source = join(run.directory, 'probe.cs') + await writeFile(source, 'internal static class SigningProbe { private static int Main() { return 0; } }\n', { flag: 'wx' }) + for (const size of [0, 1024 * 1024, 16 * 1024 * 1024]) { + controller.signal.throwIfAborted() + const resource = join(run.directory, `resource-${size}.bin`) + if (size) await writeFile(resource, Buffer.alloc(size, 0x5a), { flag: 'wx' }) + const output = join(run.directory, `unsigned-${size}.exe`) + await run.run(`compile-probe-${size}`, join(environment.SystemRoot ?? 'C:\\Windows', 'Microsoft.NET/Framework64/v4.0.30319/csc.exe'), + ['/nologo', '/target:exe', '/platform:x64', `/out:${output}`, ...(size ? [`/resource:${resource}`] : []), source], + { cwd: run.directory, env: safeEnvironment, timeoutMs: 30_000 }) + if ((await inspectWindowsRuntimeSignature(output)).status !== 'NotSigned') throw new Error('Probe must be unsigned') + templates.push(output) + } + const results: { round: number; mode: string; elapsedMs: number; files: number }[] = [] + for (let round = 1; round <= Number(values.rounds); round++) { + const order = round % 2 ? MODES : [...MODES].reverse() + for (const mode of order) { + controller.signal.throwIfAborted() + const directory = join(run.directory, `round-${round}-${mode.name}`) + await mkdir(directory) + const files: string[] = [] + for (let index = 0; index < templates.length * 2; index++) { + const file = join(directory, `probe-${index}.exe`) + await copyFile(templates[Math.floor(index / 2)]!, file) + files.push(file) + } + record({ type: 'experiment-batch-plan', round, ...mode, files }) + const attempt = beginWindowsSigningAttempt({ runDirectory: run.directory, target: directory }) + let commandIndex = 0 + const started = performance.now() + try { + await runSigningExperimentBatch(files, { + ...mode, signal: controller.signal, + sign: async (_file, path) => { + controller.signal.throwIfAborted() + const stage = `experiment-sign-${round}-${mode.name}-${commandIndex++}` + record({ type: 'experiment-hardware-start', stage, path }) + const interpreter = environment.ComSpec ?? join(environment.SystemRoot ?? 'C:\\Windows', 'System32', 'cmd.exe') + await run.run(stage, interpreter, + ['/d', '/v:off', '/c', 'windows-sign.cmd'], { + cwd: join(APP_ROOT, 'scripts'), timeoutMs: 60_000, + env: buildWindowsSigningEnvironment(safeEnvironment, { + certificateFile, signTool, tokenPin, keyContainer, path, isNest: false, + }), + }) + record({ type: 'experiment-hardware-exited', stage, path }) + const signature = await inspectWindowsRuntimeSignature(path) + if (signature.status !== 'Valid' || signature.timestamped || signature.thumbprint?.toUpperCase() !== thumbprint.toUpperCase()) { + throw new Error('Primary signature verification failed') + } + record({ type: 'experiment-hardware-verified', stage, path }) + }, + complete: async (path, sign) => { + await completeWindowsSignature(path, { + thumbprint, sign, inspect: inspectWindowsRuntimeSignature, evidenceDirectory: run.directory, record, + normalize: file => normalizeWindowsSignature(file, signTool, safeEnvironment), + timestamp: async (file) => { + await promisify(execFile)(signTool, ['timestamp', '/v', '/tr', 'http://timestamp.digicert.com', '/td', 'sha256', file], { + env: safeEnvironment, windowsHide: true, timeout: 60_000, + }) + }, + }) + const signature = await inspectWindowsRuntimeSignature(path) + if (signature.status !== 'Valid' || !signature.timestamped || signature.thumbprint?.toUpperCase() !== thumbprint.toUpperCase()) { + throw new Error('Published probe signature verification failed') + } + record({ type: 'experiment-file-verified', path, ...signature, + sha256: createHash('sha256').update(await readFile(path)).digest('hex') }) + }, + }) + controller.signal.throwIfAborted() + const result = { round, mode: mode.name, elapsedMs: performance.now() - started, files: files.length } + results.push(result) + record({ type: 'experiment-batch-complete', ...result }) + await writeFile(join(run.directory, 'measurements.json'), `${JSON.stringify(results, null, 2)}\n`, { flush: true }) + attempt.success() + console.log(`SIGNING_EXPERIMENT_SAMPLE ${JSON.stringify(result)}`) + } catch (error) { + attempt.failure(null, 'Signing concurrency experiment failed; inspect retained records before recovery') + throw error + } + } + } + }) + success = true + } finally { + process.removeListener('SIGINT', abort) + process.removeListener('SIGTERM', abort) + run.finish(success) + } +} + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + runWindowsSigningConcurrencyExperiment().catch(() => { + // Child-process errors can embed arguments; the retained supervisor output is already redacted. + console.error('Signing experiment stopped; inspect its retained record. No automatic hardware retry or interlock recovery.') + process.exitCode = 1 + }) +} diff --git a/apps/desktop/tests/windows-signing-concurrency.spec.ts b/apps/desktop/tests/windows-signing-concurrency.spec.ts new file mode 100644 index 0000000000..0359c362aa --- /dev/null +++ b/apps/desktop/tests/windows-signing-concurrency.spec.ts @@ -0,0 +1,178 @@ +/** Driver ownership tests replace every native signing and device boundary. */ +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { runWindowsSigningConcurrencyExperiment } from './windows-signing-concurrency.perf.ts' +import { loadDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs' +import { createPackagingRun } from '../scripts/packaging-run.mjs' +import { beginWindowsSigningAttempt } from '../scripts/windows-signing-state.mjs' +import { inspectWindowsTokenIdentity } from '../scripts/windows-token-identity.ts' + +const state = vi.hoisted(() => ({ + root: '', environment: {} as NodeJS.ProcessEnv, + identity: 'matched', failure: '' as '' | 'primary' | 'final' | 'audit', + signed: new Set(), completed: new Set(), stages: [] as string[], + events: [] as object[], successes: 0, failures: 0, +})) + +vi.mock('node:child_process', () => ({ + execFile: vi.fn(() => { throw new Error('Native subprocesses are forbidden in driver tests') }), +})) +vi.mock('node:os', async original => ({ ...await original(), homedir: () => state.root })) +vi.mock('node:crypto', async original => ({ + ...await original(), + X509Certificate: class { + fingerprint = 'A'.repeat(40) + validFrom = 'Jan 1 2000 GMT' + validTo = 'Jan 1 2100 GMT' + }, +})) +vi.mock('../scripts/desktop-package-environment.mjs', () => ({ + loadDesktopPackageEnvironment: vi.fn(() => state.environment), +})) +vi.mock('../scripts/windows-sign.mjs', async original => ({ + ...await original(), + createWindowsTokenSigner: vi.fn(() => { throw new Error('unconfigured constructor') }), +})) +vi.mock('../scripts/windows-token-identity.ts', () => ({ + inspectWindowsTokenIdentity: vi.fn(async () => state.identity), + windowsTokenIdentityFailure: (result: string) => `Identity refused: ${result}`, +})) +vi.mock('../scripts/windows-signing-stage.mjs', () => ({ + withWindowsSigningStage: vi.fn(async (_options: object, operation: () => Promise) => operation()), +})) +vi.mock('../scripts/packaging-run.mjs', () => ({ + createPackagingRun: vi.fn(() => ({ + directory: join(state.root, 'run'), + finish: vi.fn(), + run: vi.fn(async (stage: string, _executable: string, args: readonly string[], options: { env: NodeJS.ProcessEnv }) => { + state.stages.push(stage) + if (stage.startsWith('compile-')) { + await writeFile(args.find(arg => arg.startsWith('/out:'))!.slice(5), 'inert probe') + } else { + state.signed.add(options.env.DSH_DESKTOP_WINDOWS_SIGN_TARGET!) + } + }), + })), + recordPackagingEvent: (_directory: string, event: object) => { + if (state.failure === 'audit' && 'type' in event && event.type === 'experiment-file-verified') throw new Error('Audit failed') + state.events.push(event) + }, +})) +vi.mock('../scripts/windows-signing-state.mjs', () => ({ + beginWindowsSigningAttempt: vi.fn((options: { runDirectory: string; target: string; stateDirectory?: string }) => { + expect(options.stateDirectory).toBeUndefined() + const lock = join(state.root, '.dsh-desktop-signing', 'attempt.json') + mkdirSync(dirname(lock), { recursive: true }) + writeFileSync(lock, 'batch attempt', { flag: 'wx' }) + return { + success: () => { + const verified = state.events.filter(event => 'type' in event && event.type === 'experiment-file-verified' + && 'path' in event && typeof event.path === 'string' && dirname(event.path) === options.target) + expect(verified).toHaveLength(6) + expect(readFileSync(join(options.runDirectory, 'measurements.json'), 'utf8')).toContain('"files": 6') + unlinkSync(lock) + state.successes++ + }, + failure: () => { state.failures++ }, + } + }), +})) +vi.mock('../scripts/windows-runtime-signature.mjs', () => ({ + inspectWindowsRuntimeSignature: vi.fn(async (path: string) => { + if (!state.signed.has(path)) return { status: 'NotSigned', timestamped: false, thumbprint: null } + const timestamped = state.completed.has(path) + const failed = state.failure === (timestamped ? 'final' : 'primary') + return { status: failed ? 'HashMismatch' : 'Valid', timestamped, thumbprint: 'A'.repeat(40) } + }), +})) +vi.mock('../scripts/windows-timestamp.mjs', () => ({ + completeWindowsSignature: vi.fn(async (path: string, options: { sign: (path: string) => Promise }) => { + await options.sign(path) + state.completed.add(path) + }), + normalizeWindowsSignature: vi.fn(() => { throw new Error('Native normalization is forbidden in driver tests') }), +})) + +const platform = Object.getOwnPropertyDescriptor(process, 'platform')! +beforeEach(async () => { + vi.clearAllMocks() + state.root = await mkdtemp(join(tmpdir(), 'signing-driver-')) + mkdirSync(join(state.root, 'run')) + state.identity = 'matched'; state.failure = ''; state.successes = 0; state.failures = 0 + state.signed.clear(); state.completed.clear(); state.stages = []; state.events = [] + state.environment = { + DSH_DESKTOP_WINDOWS_CER_FILE: join(state.root, 'certificate.cer'), + DSH_DESKTOP_WINDOWS_SIGNTOOL: 'forbidden-signer', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'fixture-pin', + DSH_DESKTOP_WINDOWS_KEY_CONTAINER: ' fixture-container ', API_KEY: 'fixture-secret', NODE_OPTIONS: 'forbidden-option', + } + await writeFile(state.environment.DSH_DESKTOP_WINDOWS_CER_FILE!, 'inert certificate') + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) + const { createWindowsTokenSigner } = await import('../scripts/windows-sign.mjs') + vi.mocked(createWindowsTokenSigner).mockReturnValue(vi.fn(async () => { throw new Error('Production signer is forbidden') })) + vi.spyOn(console, 'log').mockImplementation(() => {}) +}) +afterEach(async () => { + Object.defineProperty(process, 'platform', platform) + vi.useRealTimers() + vi.restoreAllMocks() + await rm(state.root, { recursive: true, force: true, maxRetries: 3 }) +}) + +const execute = () => runWindowsSigningConcurrencyExperiment(['--execute', '--config-root', state.root, '--rounds', '1']) +const lock = () => join(state.root, '.dsh-desktop-signing', 'attempt.json') + +it('requires opt-in before reading configuration or querying a device', async () => { + await runWindowsSigningConcurrencyExperiment([]) + expect(loadDesktopPackageEnvironment).not.toHaveBeenCalled() + expect(inspectWindowsTokenIdentity).not.toHaveBeenCalled() + expect(createPackagingRun).not.toHaveBeenCalled() +}) + +it.each(['1999-01-01', '2101-01-01'])('rejects certificate validity at %s before querying or signing', async (date) => { + vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(new Date(date)) + await expect(execute()).rejects.toThrow('currently valid') + expect(inspectWindowsTokenIdentity).not.toHaveBeenCalled() + expect(beginWindowsSigningAttempt).not.toHaveBeenCalled() + expect(state.stages).toEqual([]) +}) + +it.each(['certificate-mismatch', 'provider-unavailable', 'container-unavailable', 'unknown'])('refuses public identity %s before any signing', async (identity) => { + state.identity = identity + await expect(execute()).rejects.toThrow('Identity refused') + expect(state.stages).toEqual([]) + expect(beginWindowsSigningAttempt).not.toHaveBeenCalled() +}) + +it('retains an existing default-account interlock without accessing the device', async () => { + mkdirSync(dirname(lock()), { recursive: true }); writeFileSync(lock(), 'prior failure') + await expect(execute()).rejects.toThrow('Retained signing interlock') + expect(inspectWindowsTokenIdentity).not.toHaveBeenCalled() + expect(readFileSync(lock(), 'utf8')).toBe('prior failure') +}) + +it('completes all three modes and releases each default-account batch only after recording every verification', async () => { + await execute() + expect(state.successes).toBe(3) + expect(state.failures).toBe(0) + expect(state.signed.size).toBe(18) + expect(existsSync(lock())).toBe(false) + expect(beginWindowsSigningAttempt).toHaveBeenCalledTimes(3) + expect(inspectWindowsTokenIdentity).toHaveBeenCalledWith(expect.objectContaining({ + container: 'fixture-container', environment: {}, + })) + expect(JSON.stringify(state.events)).not.toMatch(/fixture-pin|fixture-secret|fixture-container/u) +}) + +it.each(['primary', 'final', 'audit'] as const)('retains the batch interlock and stops later modes after %s failure', async (failure) => { + state.failure = failure + await expect(execute()).rejects.toThrow(failure === 'audit' ? 'Audit failed' : 'signature verification failed') + expect(state.successes).toBe(0) + expect(state.failures).toBe(1) + expect(beginWindowsSigningAttempt).toHaveBeenCalledOnce() + expect(state.signed.size).toBe(1) + expect(existsSync(lock())).toBe(true) + expect(state.stages.some(stage => stage.includes('pipeline') || stage.includes('hardware2'))).toBe(false) +}) diff --git a/apps/desktop/tests/windows-signing-experiment.spec.ts b/apps/desktop/tests/windows-signing-experiment.spec.ts new file mode 100644 index 0000000000..70b1404512 --- /dev/null +++ b/apps/desktop/tests/windows-signing-experiment.spec.ts @@ -0,0 +1,258 @@ +import { expect, it, vi } from 'vitest' +import { runSigningExperimentBatch } from './helpers/windows-signing-experiment.ts' + +function barrier() { + const pending = Promise.withResolvers() + return { promise: pending.promise, resolve: () => { pending.resolve(undefined) } } +} +const observe = (operation: Promise) => operation.then( + () => ({ ok: true as const }), (error: unknown) => ({ ok: false as const, error }), +) + +it('finishes each item before beginning the next in serial mode', async () => { + const events: string[] = [] + await runSigningExperimentBatch([1, 2], { + jobs: 1, hardwareConcurrency: 1, + sign: async (item, path) => { expect(path).toBe(`staged-${item}`); events.push(`sign ${item}`) }, + complete: async (item, sign) => { + events.push(`start ${item}`) + await sign(`staged-${item}`) + events.push(`complete ${item}`) + }, + }) + expect(events).toEqual(['start 1', 'sign 1', 'complete 1', 'start 2', 'sign 2', 'complete 2']) +}) + +it('overlaps completion with the next signature while keeping hardware concurrency at one', async () => { + const firstSigning = barrier() + const releaseFirstSignature = barrier() + const secondStarted = barrier() + const firstPost = barrier() + const secondSigned = barrier() + const releaseCompletion = barrier() + const started: number[] = [] + let active = 0 + let peak = 0 + const outcome = observe(runSigningExperimentBatch([1, 2, 3], { + jobs: 2, hardwareConcurrency: 1, + sign: async (item) => { + active++ + peak = Math.max(peak, active) + try { + if (item === 1) { firstSigning.resolve(); await releaseFirstSignature.promise } + if (item === 2) secondSigned.resolve() + } finally { active-- } + }, + complete: async (item, sign) => { + started.push(item) + if (item === 2) secondStarted.resolve() + await sign(`staged-${item}`) + if (item === 1) firstPost.resolve() + await releaseCompletion.promise + }, + })) + try { + await Promise.all([firstSigning.promise, secondStarted.promise]) + expect(active).toBe(1) + releaseFirstSignature.resolve() + await Promise.all([firstPost.promise, secondSigned.promise]) + expect(started).toEqual([1, 2]) + expect(peak).toBe(1) + } finally { + releaseFirstSignature.resolve() + releaseCompletion.resolve() + expect(await outcome).toEqual({ ok: true }) + } + expect(started).toEqual([1, 2, 3]) +}) + +it('allows two hardware requests but does not start a third item while both jobs are occupied', async () => { + const bothSigning = barrier() + const release = barrier() + const started: number[] = [] + let active = 0 + let peak = 0 + const outcome = observe(runSigningExperimentBatch([1, 2, 3], { + jobs: 2, hardwareConcurrency: 2, + sign: async () => { + active++ + peak = Math.max(peak, active) + if (active === 2) bothSigning.resolve() + try { await release.promise } finally { active-- } + }, + complete: async (item, sign) => { started.push(item); await sign(`staged-${item}`) }, + })) + try { + await bothSigning.promise + expect(started).toEqual([1, 2]) + expect(active).toBe(2) + } finally { release.resolve(); expect(await outcome).toEqual({ ok: true }) } + expect(peak).toBe(2) + expect(active).toBe(0) +}) + +it.each([new Error('hardware failed'), undefined])('stops queued signatures and new items after a hardware rejection (%s)', async (failure) => { + const secondStarted = barrier() + const sign = vi.fn(async () => { await secondStarted.promise; throw failure }) + const started: number[] = [] + const result = await observe(runSigningExperimentBatch([1, 2, 3], { + jobs: 2, hardwareConcurrency: 1, sign, + complete: async (item, request) => { + started.push(item) + if (item === 2) secondStarted.resolve() + await request(`staged-${item}`) + }, + })) + expect(result).toEqual({ ok: false, error: failure }) + expect(sign).toHaveBeenCalledTimes(1) + expect(started).toEqual([1, 2]) +}) + +it('drains concurrent hardware after the first failure and retains that failure', async () => { + const secondSigning = barrier() + const firstFailed = barrier() + const releaseSecond = barrier() + const failure = new Error('first hardware failure') + const calls: number[] = [] + let active = 0 + let settled = false + const outcome = observe(runSigningExperimentBatch([1, 2, 3], { + jobs: 2, hardwareConcurrency: 2, + sign: async (item) => { + calls.push(item) + active++ + try { + if (item === 1) { await secondSigning.promise; throw failure } + secondSigning.resolve() + await releaseSecond.promise + throw new Error('later hardware failure') + } finally { active-- } + }, + complete: async (item, sign) => { + try { await sign(`staged-${item}`) } + catch (error) { if (item === 1) firstFailed.resolve(); throw error } + }, + })).then((result) => { settled = true; return result }) + try { + await firstFailed.promise + expect(settled).toBe(false) + expect(active).toBe(1) + expect(calls).toEqual([1, 2]) + } finally { releaseSecond.resolve(); expect(await outcome).toEqual({ ok: false, error: failure }) } + expect(active).toBe(0) + expect(calls).toEqual([1, 2]) +}) + +it('preserves an undefined completion failure and starts no further work', async () => { + const sign = vi.fn(async () => {}) + const complete = vi.fn(async () => { throw undefined }) + expect(await observe(runSigningExperimentBatch([1, 2], { + jobs: 1, hardwareConcurrency: 1, complete, sign, + }))).toEqual({ ok: false, error: undefined }) + expect(complete).toHaveBeenCalledTimes(1) + expect(sign).not.toHaveBeenCalled() +}) + +it('prevents a waiting signature when completion fails and drains the issued hardware request', async () => { + const secondSigning = barrier() + const releaseSecond = barrier() + const failureObserved = barrier() + const failure = new Error('timestamp failed') + const calls: number[] = [] + let pending: ReturnType | undefined + let settled = false + const outcome = observe(runSigningExperimentBatch([1, 2, 3], { + jobs: 2, hardwareConcurrency: 1, + sign: async (item) => { + calls.push(item) + if (item === 2) { secondSigning.resolve(); await releaseSecond.promise } + }, + complete: async (item, sign) => { + await sign(`staged-${item}`) + if (item === 1) { + await secondSigning.promise + // Failure can interrupt a completion that already owns a queued request. + const waiting = observe(sign(`staged-retry-${item}`)).then((result) => { failureObserved.resolve(); return result }) + pending = waiting + throw failure + } + }, + })).then((result) => { settled = true; return result }) + try { + await failureObserved.promise + expect(settled).toBe(false) + expect(calls).toEqual([1, 2]) + } finally { releaseSecond.resolve(); expect(await outcome).toEqual({ ok: false, error: failure }) } + expect(calls).toEqual([1, 2]) + expect(await pending).toEqual({ ok: false, error: failure }) +}) + +it('drains both completion workers after one fails without starting more items', async () => { + const secondPost = barrier() + const releaseSecondPost = barrier() + const failed = barrier() + const failure = new Error('verification failed') + const started: number[] = [] + let settled = false + const outcome = observe(runSigningExperimentBatch([1, 2, 3], { + jobs: 2, hardwareConcurrency: 2, sign: async () => {}, + complete: async (item, sign) => { + started.push(item) + await sign(`staged-${item}`) + if (item === 1) { await secondPost.promise; failed.resolve(); throw failure } + secondPost.resolve() + await releaseSecondPost.promise + }, + })).then((result) => { settled = true; return result }) + try { + await failed.promise + expect(settled).toBe(false) + expect(started).toEqual([1, 2]) + } finally { releaseSecondPost.resolve(); expect(await outcome).toEqual({ ok: false, error: failure }) } + expect(started).toEqual([1, 2]) +}) + +it('cancels waiting signatures and new items while waiting for issued hardware to finish', async () => { + const controller = new AbortController() + const secondStarted = barrier() + const release = barrier() + const waitingRejected = barrier() + const reason = new Error('operator cancelled') + const calls: number[] = [] + let active = false + let settled = false + const outcome = observe(runSigningExperimentBatch([1, 2, 3], { + jobs: 2, hardwareConcurrency: 1, signal: controller.signal, + sign: async (item) => { + calls.push(item) + active = true + try { await release.promise } finally { active = false } + }, + complete: async (item, sign) => { + if (item === 2) secondStarted.resolve() + try { await sign(`staged-${item}`) } + catch (error) { if (item === 2) waitingRejected.resolve(); throw error } + }, + })).then((result) => { settled = true; return result }) + try { + await secondStarted.promise + controller.abort(reason) + await waitingRejected.promise + expect(active).toBe(true) + expect(settled).toBe(false) + expect(calls).toEqual([1]) + } finally { release.resolve(); expect(await outcome).toEqual({ ok: false, error: reason }) } + expect(active).toBe(false) + expect(calls).toEqual([1]) +}) + +it('does not begin work when cancellation was already requested', async () => { + const reason = new Error('already cancelled') + const complete = vi.fn(async () => {}) + const sign = vi.fn(async () => {}) + expect(await observe(runSigningExperimentBatch([1], { + jobs: 2, hardwareConcurrency: 2, signal: AbortSignal.abort(reason), complete, sign, + }))).toEqual({ ok: false, error: reason }) + expect(complete).not.toHaveBeenCalled() + expect(sign).not.toHaveBeenCalled() +}) diff --git a/apps/desktop/windows-signing/README.i18n.yaml b/apps/desktop/windows-signing/README.i18n.yaml index e1bbf64fb5..6446fe0ffc 100644 --- a/apps/desktop/windows-signing/README.i18n.yaml +++ b/apps/desktop/windows-signing/README.i18n.yaml @@ -36,8 +36,8 @@ en: 6e8208d19ad510e5 zh: eee42e8938c0f603 /windows-signing-and-runner-operations/verification-limits: - en: 94a471410c84a1c7 - zh: 115228e4ca36581d + en: e04b54cec106c2b7 + zh: 1941e4b0f0249c20 /windows-signing-and-runner-operations/dev-note: en: b75c8458928b72b9 zh: 003ba4e17b4b5269 diff --git a/apps/desktop/windows-signing/README.md b/apps/desktop/windows-signing/README.md index b0550d296e..b3b33756cf 100644 --- a/apps/desktop/windows-signing/README.md +++ b/apps/desktop/windows-signing/README.md @@ -106,6 +106,8 @@ Use `pnpm --dir apps/desktop run cache:windows-signatures --usage` to inspect st Real hardware and installation evidence is owned by the [Desktop qualification record](../tests/README.md) and [installed-update procedure](../tests/installed-update/README.md). This document does not authorize PIN retries, automatic interlock deletion, device initialization or changes to the accepted isolation policy. +For operator-approved concurrency experiments, run `node apps/desktop/tests/windows-signing-concurrency.perf.ts --execute --config-root --rounds 2`. Without `--execute`, it only prints usage. The diagnostic compares serial completion, two completion workers with one hardware request, and two concurrent hardware requests, using fresh copies of six synthetic PE files per batch; the second round reverses mode order. It uses the machine test preset, reads public identity first, holds the account signing-stage lock and retains one hardware-attempt interlock per batch. It stops dispatch on the first failure and drains issued work; up to two hardware calls may already be in flight. Only complete signature, timestamp and file verification permits normal interlock release. Records and probes remain under `.desktop-build/qualification/signing-concurrency`; probes are never executed and no release cache is used. Timings exclude probe preparation, lock waiting and package assembly. This experiment does not enable production concurrency or establish full-release performance. + ## Dev Note None. diff --git a/apps/desktop/windows-signing/README.zh.md b/apps/desktop/windows-signing/README.zh.md index 66c7d0fa92..7ac6cda265 100644 --- a/apps/desktop/windows-signing/README.zh.md +++ b/apps/desktop/windows-signing/README.zh.md @@ -106,6 +106,8 @@ Windows 打包从 `.env.windows` 或所选预设读取 `DSH_DESKTOP_WINDOWS_PACK 真实硬件与安装证据由[Desktop 验证记录](../tests/README.zh.md)和[已安装更新流程](../tests/installed-update/README.zh.md)维护。本文档不授权 PIN 重试、自动删除 interlock、设备初始化或改变已接受的隔离策略。 +经操作人员授权后,可运行 `node apps/desktop/tests/windows-signing-concurrency.perf.ts --execute --config-root <打包配置目录的绝对路径> --rounds 2` 做并发实验。不加 `--execute` 只打印用法。诊断比较完全串行、两个完成任务共用单路硬件签名,以及两路硬件签名;每批使用六个合成 PE 文件的全新副本,第二轮反转模式顺序。实验读取机器 test preset,先核对公开身份,持有账户签名阶段锁,每批保留一条硬件尝试保护记录。首错停止派发并排空已发出的工作;最多可能已有两条硬件请求在途。只有全部签名、时间戳和文件验证成功才正常释放保护记录。记录与探针保留在 `.desktop-build/qualification/signing-concurrency`,探针从不执行,也不使用发布签名缓存。计时不含探针准备、等锁及包组装,实验不启用生产并发,也不代表完整发布性能。 + ## 开发备注 无。 From f5caf31fb5d96a29c477f02f97ba8fcce8557a9d Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:04:53 +0800 Subject: [PATCH 26/46] test(desktop): qualify signing concurrency with release files --- .../tests/helpers/windows-signing-corpus.ts | 52 ++++++++++++ .../helpers/windows-signing-experiment.ts | 4 +- .../tests/windows-signing-concurrency.perf.ts | 79 +++++++++++++------ .../tests/windows-signing-concurrency.spec.ts | 61 +++++++++++++- .../tests/windows-signing-corpus.spec.ts | 71 +++++++++++++++++ .../tests/windows-signing-experiment.spec.ts | 44 +++++++++-- apps/desktop/windows-signing/README.i18n.yaml | 4 +- apps/desktop/windows-signing/README.md | 6 +- apps/desktop/windows-signing/README.zh.md | 6 +- scripts/windows-package-workflow.spec.ts | 19 +++-- 10 files changed, 298 insertions(+), 48 deletions(-) create mode 100644 apps/desktop/tests/helpers/windows-signing-corpus.ts create mode 100644 apps/desktop/tests/windows-signing-corpus.spec.ts diff --git a/apps/desktop/tests/helpers/windows-signing-corpus.ts b/apps/desktop/tests/helpers/windows-signing-corpus.ts new file mode 100644 index 0000000000..c12b936422 --- /dev/null +++ b/apps/desktop/tests/helpers/windows-signing-corpus.ts @@ -0,0 +1,52 @@ +/** Snapshot operator-selected release files into unsigned, hash-pinned experiment inputs. */ +import { createHash } from 'node:crypto' +import { copyFile, lstat, mkdir, readFile } from 'node:fs/promises' +import { extname, isAbsolute, join } from 'node:path' +import { inspectWindowsRuntimeSignature } from '../../scripts/windows-runtime-signature.mjs' + +/** One immutable unsigned input; its source is never modified or executed. */ +export interface SigningCorpusFile { + readonly path: string + readonly sha256: string + readonly bytes: number +} + +/** + * Copy a nonempty JSON array of absolute paths and SHA-256 digests, then strip signatures only on copies. + * @param manifestPath Operator-owned JSON manifest; every source must be a regular file with the pinned digest. + * @param directory New private directory for retained unsigned templates. + * @param removeSignature Public SignTool removal operation; never receives an original source path. + * @param signal Stops preparation before the next file when aborted. + * @returns Unsigned template digests and sizes in manifest order; rejects invalid signatures or changed inputs. + */ +export async function prepareSigningCorpus( + manifestPath: string, directory: string, removeSignature: (path: string) => Promise, signal: AbortSignal, +): Promise { + const manifest: unknown = JSON.parse(await readFile(manifestPath, 'utf8')) + if (!Array.isArray(manifest) || manifest.length === 0) throw new Error('Signing corpus must be a nonempty JSON array') + const inputs = manifest.map((entry: unknown) => { + if (typeof entry !== 'object' || entry === null || !('path' in entry) || typeof entry.path !== 'string' + || !isAbsolute(entry.path) || !('sha256' in entry) || typeof entry.sha256 !== 'string' + || !/^[a-f0-9]{64}$/u.test(entry.sha256)) throw new Error('Signing corpus requires absolute paths and lowercase SHA-256 digests') + return { path: entry.path, sha256: entry.sha256 } + }) + await mkdir(directory) + const result: SigningCorpusFile[] = [] + for (const [index, input] of inputs.entries()) { + signal.throwIfAborted() + const stat = await lstat(input.path) + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('Signing corpus source must be a regular file') + const path = join(directory, `${index}${extname(input.path) || '.exe'}`) + await copyFile(input.path, path) + if (createHash('sha256').update(await readFile(path)).digest('hex') !== input.sha256) { + throw new Error('Signing corpus source differs from its pinned digest') + } + const before = await inspectWindowsRuntimeSignature(path) + if (before.status === 'Valid') await removeSignature(path) + else if (before.status !== 'NotSigned') throw new Error('Signing corpus source has an invalid signature') + if ((await inspectWindowsRuntimeSignature(path)).status !== 'NotSigned') throw new Error('Signing corpus copy must be unsigned') + const content = await readFile(path) + result.push({ path, sha256: createHash('sha256').update(content).digest('hex'), bytes: content.length }) + } + return result +} diff --git a/apps/desktop/tests/helpers/windows-signing-experiment.ts b/apps/desktop/tests/helpers/windows-signing-experiment.ts index b85056e7bb..f9ccac9eda 100644 --- a/apps/desktop/tests/helpers/windows-signing-experiment.ts +++ b/apps/desktop/tests/helpers/windows-signing-experiment.ts @@ -2,8 +2,8 @@ /** Operations owned by one experiment, with independently bounded completion and hardware work. */ export interface SigningExperimentOptions { - readonly jobs: 1 | 2 - readonly hardwareConcurrency: 1 | 2 + readonly jobs: 1 | 2 | 4 + readonly hardwareConcurrency: 1 | 2 | 4 readonly signal?: AbortSignal /** Complete one item, awaiting the supplied signing operation and all subsequent work before returning. */ readonly complete: (item: T, sign: (path: string) => Promise) => Promise diff --git a/apps/desktop/tests/windows-signing-concurrency.perf.ts b/apps/desktop/tests/windows-signing-concurrency.perf.ts index ad135c92ae..93c1eb3f18 100644 --- a/apps/desktop/tests/windows-signing-concurrency.perf.ts +++ b/apps/desktop/tests/windows-signing-concurrency.perf.ts @@ -1,10 +1,10 @@ -/** Operator-only comparison of serial signing, post-sign overlap and two concurrent hardware requests. */ +/** Operator-only comparison of serial signing and bounded concurrent signature completion. */ import { execFile } from 'node:child_process' import { createHash, X509Certificate } from 'node:crypto' import { existsSync } from 'node:fs' import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises' -import { homedir, cpus, totalmem } from 'node:os' -import { isAbsolute, join, resolve } from 'node:path' +import { homedir, cpus, totalmem, freemem } from 'node:os' +import { extname, isAbsolute, join, resolve } from 'node:path' import { parseArgs, promisify } from 'node:util' import { fileURLToPath } from 'node:url' import { loadDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs' @@ -16,6 +16,7 @@ import { buildWindowsSigningEnvironment, createWindowsTokenSigner, scrubWindowsS import { inspectWindowsRuntimeSignature } from '../scripts/windows-runtime-signature.mjs' import { completeWindowsSignature, normalizeWindowsSignature } from '../scripts/windows-timestamp.mjs' import { runSigningExperimentBatch } from './helpers/windows-signing-experiment.ts' +import { prepareSigningCorpus } from './helpers/windows-signing-corpus.ts' const APP_ROOT = fileURLToPath(new URL('..', import.meta.url)) const MODES = [ @@ -23,27 +24,39 @@ const MODES = [ { name: 'pipeline', jobs: 2, hardwareConcurrency: 1 }, { name: 'hardware2', jobs: 2, hardwareConcurrency: 2 }, ] as const +const CORPUS_MODES = [ + { name: 'serial', jobs: 1, hardwareConcurrency: 1 }, + { name: 'hardware2', jobs: 2, hardwareConcurrency: 2 }, + { name: 'hardware4', jobs: 4, hardwareConcurrency: 4 }, +] as const /** - * Run an explicitly opted-in hardware experiment, retaining all generated probes and audit records. + * Run an explicitly opted-in hardware experiment, retaining unsigned inputs, signed copies and audit records. * @param args CLI arguments; omission uses the current process arguments. - * @returns Resolves after all requested batches verify; any failure retains the batch interlock and stops later modes. + * @returns Resolves after all requested batches verify; a failure during a batch retains its interlock and stops later modes. */ export async function runWindowsSigningConcurrencyExperiment(args = process.argv.slice(2)): Promise { const { values } = parseArgs({ args, options: { execute: { type: 'boolean' }, 'config-root': { type: 'string' }, rounds: { type: 'string', default: '2' }, + corpus: { type: 'string' }, 'pipeline-control': { type: 'boolean' }, 'strip-tool': { type: 'string' }, } }) if (!values.execute) { - console.log('Operator hardware experiment: node apps/desktop/tests/windows-signing-concurrency.perf.ts --execute --config-root [--rounds 1|2]') + console.log('Operator hardware experiment: node apps/desktop/tests/windows-signing-concurrency.perf.ts --execute --config-root [--rounds 1|2] [--corpus ] [--strip-tool ] [--pipeline-control]') return } if (process.platform !== 'win32' || !values['config-root'] || !isAbsolute(values['config-root']) || !['1', '2'].includes(values.rounds)) throw new Error('Requires Windows, an absolute config-root and rounds 1 or 2') + if (values.corpus !== undefined && !isAbsolute(values.corpus)) throw new Error('Signing corpus manifest must be an absolute path') + if (values['pipeline-control'] && !values.corpus) throw new Error('Pipeline control requires a real-file corpus') + if (values['strip-tool'] !== undefined && (!values.corpus || !isAbsolute(values['strip-tool']))) throw new Error('Signature stripping override requires a corpus and an absolute SignTool path') + const modes = values['pipeline-control'] ? [{ name: 'pipeline4', jobs: 4, hardwareConcurrency: 1 } as const] + : values.corpus ? CORPUS_MODES : MODES const environment = loadDesktopPackageEnvironment('win32', { ...process.env, DSH_PACKAGING_PRESET: 'test', DSH_PACKAGING_CONFIG_DIR: values['config-root'], }) const certificateFile = environment.DSH_DESKTOP_WINDOWS_CER_FILE! const signTool = environment.DSH_DESKTOP_WINDOWS_SIGNTOOL! + const stripTool = values['strip-tool'] ?? signTool const tokenPin = environment.DSH_DESKTOP_WINDOWS_TOKEN_PIN! const keyContainer = environment.DSH_DESKTOP_WINDOWS_KEY_CONTAINER!.trim() // Construction validates the same signing inputs; the production serial hook is never invoked here. @@ -60,7 +73,7 @@ export async function runWindowsSigningConcurrencyExperiment(args = process.argv const run = createPackagingRun(join(APP_ROOT, '.desktop-build', 'qualification', 'signing-concurrency'), { kind: 'operator-signing-concurrency', certificateSha256: createHash('sha256').update(certificate).digest('hex'), node: process.version, cpu: cpus()[0]?.model, logicalCpus: cpus().length, memoryBytes: totalmem(), - rounds: Number(values.rounds), resourceBytes: [0, 1024 * 1024, 16 * 1024 * 1024], filesPerBatch: 6, + rounds: Number(values.rounds), corpus: values.corpus ?? null, stripTool, modes, cache: 'disabled', probeExecuted: false, }, { parallel: true, secrets: [tokenPin, keyContainer] }) console.log(`SIGNING_EXPERIMENT_RECORD ${run.directory}`) @@ -78,31 +91,43 @@ export async function runWindowsSigningConcurrencyExperiment(args = process.argv const identity = await inspectWindowsTokenIdentity({ certificate, container: keyContainer, environment: safeEnvironment }) record({ type: 'experiment-public-identity', result: identity, pinSupplied: false }) if (identity !== 'matched') throw new Error(windowsTokenIdentityFailure(identity)) - const templates: string[] = [] - const source = join(run.directory, 'probe.cs') - await writeFile(source, 'internal static class SigningProbe { private static int Main() { return 0; } }\n', { flag: 'wx' }) - for (const size of [0, 1024 * 1024, 16 * 1024 * 1024]) { - controller.signal.throwIfAborted() - const resource = join(run.directory, `resource-${size}.bin`) - if (size) await writeFile(resource, Buffer.alloc(size, 0x5a), { flag: 'wx' }) - const output = join(run.directory, `unsigned-${size}.exe`) - await run.run(`compile-probe-${size}`, join(environment.SystemRoot ?? 'C:\\Windows', 'Microsoft.NET/Framework64/v4.0.30319/csc.exe'), - ['/nologo', '/target:exe', '/platform:x64', `/out:${output}`, ...(size ? [`/resource:${resource}`] : []), source], - { cwd: run.directory, env: safeEnvironment, timeoutMs: 30_000 }) - if ((await inspectWindowsRuntimeSignature(output)).status !== 'NotSigned') throw new Error('Probe must be unsigned') - templates.push(output) + let templates: string[] = [] + if (values.corpus) { + let removalIndex = 0 + const corpus = await prepareSigningCorpus(values.corpus, join(run.directory, 'unsigned'), async (path) => { + await run.run(`remove-signature-${removalIndex++}`, stripTool, ['remove', '/s', path], + { cwd: run.directory, env: safeEnvironment, timeoutMs: 60_000 }) + }, controller.signal) + await writeFile(join(run.directory, 'corpus.json'), `${JSON.stringify(corpus, null, 2)}\n`, { flag: 'wx' }) + templates = corpus.map(file => file.path) + record({ type: 'experiment-corpus', files: corpus.length, bytes: corpus.reduce((sum, file) => sum + file.bytes, 0) }) + } else { + const source = join(run.directory, 'probe.cs') + await writeFile(source, 'internal static class SigningProbe { private static int Main() { return 0; } }\n', { flag: 'wx' }) + for (const size of [0, 1024 * 1024, 16 * 1024 * 1024]) { + controller.signal.throwIfAborted() + const resource = join(run.directory, `resource-${size}.bin`) + if (size) await writeFile(resource, Buffer.alloc(size, 0x5a), { flag: 'wx' }) + const output = join(run.directory, `unsigned-${size}.exe`) + await run.run(`compile-probe-${size}`, join(environment.SystemRoot ?? 'C:\\Windows', 'Microsoft.NET/Framework64/v4.0.30319/csc.exe'), + ['/nologo', '/target:exe', '/platform:x64', `/out:${output}`, ...(size ? [`/resource:${resource}`] : []), source], + { cwd: run.directory, env: safeEnvironment, timeoutMs: 30_000 }) + if ((await inspectWindowsRuntimeSignature(output)).status !== 'NotSigned') throw new Error('Probe must be unsigned') + templates.push(output, output) + } } const results: { round: number; mode: string; elapsedMs: number; files: number }[] = [] for (let round = 1; round <= Number(values.rounds); round++) { - const order = round % 2 ? MODES : [...MODES].reverse() + const order = round % 2 ? modes : [...modes].reverse() for (const mode of order) { controller.signal.throwIfAborted() const directory = join(run.directory, `round-${round}-${mode.name}`) await mkdir(directory) const files: string[] = [] - for (let index = 0; index < templates.length * 2; index++) { - const file = join(directory, `probe-${index}.exe`) - await copyFile(templates[Math.floor(index / 2)]!, file) + for (const [index, template] of templates.entries()) { + const file = join(directory, `probe-${index}${extname(template)}`) + await copyFile(template, file) + if (Buffer.compare(await readFile(template), await readFile(file)) !== 0) throw new Error('Signing experiment batch copy differs from its template') files.push(file) } record({ type: 'experiment-batch-plan', round, ...mode, files }) @@ -110,6 +135,7 @@ export async function runWindowsSigningConcurrencyExperiment(args = process.argv let commandIndex = 0 const started = performance.now() try { + record({ type: 'experiment-resources', round, mode: mode.name, point: 'start', freeMemoryBytes: freemem(), cpuTimes: cpus().map(cpu => cpu.times), processMemory: process.memoryUsage() }) await runSigningExperimentBatch(files, { ...mode, signal: controller.signal, sign: async (_file, path) => { @@ -132,6 +158,7 @@ export async function runWindowsSigningConcurrencyExperiment(args = process.argv record({ type: 'experiment-hardware-verified', stage, path }) }, complete: async (path, sign) => { + const fileStarted = performance.now() await completeWindowsSignature(path, { thumbprint, sign, inspect: inspectWindowsRuntimeSignature, evidenceDirectory: run.directory, record, normalize: file => normalizeWindowsSignature(file, signTool, safeEnvironment), @@ -145,12 +172,14 @@ export async function runWindowsSigningConcurrencyExperiment(args = process.argv if (signature.status !== 'Valid' || !signature.timestamped || signature.thumbprint?.toUpperCase() !== thumbprint.toUpperCase()) { throw new Error('Published probe signature verification failed') } - record({ type: 'experiment-file-verified', path, ...signature, + record({ type: 'experiment-file-verified', path, ...signature, elapsedMs: performance.now() - fileStarted, + freeMemoryBytes: freemem(), processMemory: process.memoryUsage(), sha256: createHash('sha256').update(await readFile(path)).digest('hex') }) }, }) controller.signal.throwIfAborted() const result = { round, mode: mode.name, elapsedMs: performance.now() - started, files: files.length } + record({ type: 'experiment-resources', round, mode: mode.name, point: 'end', freeMemoryBytes: freemem(), cpuTimes: cpus().map(cpu => cpu.times), processMemory: process.memoryUsage() }) results.push(result) record({ type: 'experiment-batch-complete', ...result }) await writeFile(join(run.directory, 'measurements.json'), `${JSON.stringify(results, null, 2)}\n`, { flush: true }) diff --git a/apps/desktop/tests/windows-signing-concurrency.spec.ts b/apps/desktop/tests/windows-signing-concurrency.spec.ts index 0359c362aa..bb82cd7062 100644 --- a/apps/desktop/tests/windows-signing-concurrency.spec.ts +++ b/apps/desktop/tests/windows-signing-concurrency.spec.ts @@ -9,12 +9,14 @@ import { loadDesktopPackageEnvironment } from '../scripts/desktop-package-enviro import { createPackagingRun } from '../scripts/packaging-run.mjs' import { beginWindowsSigningAttempt } from '../scripts/windows-signing-state.mjs' import { inspectWindowsTokenIdentity } from '../scripts/windows-token-identity.ts' +import { prepareSigningCorpus } from './helpers/windows-signing-corpus.ts' const state = vi.hoisted(() => ({ root: '', environment: {} as NodeJS.ProcessEnv, identity: 'matched', failure: '' as '' | 'primary' | 'final' | 'audit', signed: new Set(), completed: new Set(), stages: [] as string[], events: [] as object[], successes: 0, failures: 0, + removals: [] as { executable: string; args: readonly string[]; environment: NodeJS.ProcessEnv }[], })) vi.mock('node:child_process', () => ({ @@ -40,6 +42,17 @@ vi.mock('../scripts/windows-token-identity.ts', () => ({ inspectWindowsTokenIdentity: vi.fn(async () => state.identity), windowsTokenIdentityFailure: (result: string) => `Identity refused: ${result}`, })) +vi.mock('./helpers/windows-signing-corpus.ts', () => ({ + prepareSigningCorpus: vi.fn(async () => { + const files = [] + for (let index = 0; index < 6; index++) { + const path = join(state.root, `real-${index}.dll`) + await writeFile(path, 'inert real file') + files.push({ path, bytes: 15, sha256: 'a'.repeat(64) }) + } + return files + }), +})) vi.mock('../scripts/windows-signing-stage.mjs', () => ({ withWindowsSigningStage: vi.fn(async (_options: object, operation: () => Promise) => operation()), })) @@ -47,10 +60,12 @@ vi.mock('../scripts/packaging-run.mjs', () => ({ createPackagingRun: vi.fn(() => ({ directory: join(state.root, 'run'), finish: vi.fn(), - run: vi.fn(async (stage: string, _executable: string, args: readonly string[], options: { env: NodeJS.ProcessEnv }) => { + run: vi.fn(async (stage: string, executable: string, args: readonly string[], options: { env: NodeJS.ProcessEnv }) => { state.stages.push(stage) if (stage.startsWith('compile-')) { await writeFile(args.find(arg => arg.startsWith('/out:'))!.slice(5), 'inert probe') + } else if (stage.startsWith('remove-signature-')) { + state.removals.push({ executable, args, environment: options.env }) } else { state.signed.add(options.env.DSH_DESKTOP_WINDOWS_SIGN_TARGET!) } @@ -102,7 +117,7 @@ beforeEach(async () => { state.root = await mkdtemp(join(tmpdir(), 'signing-driver-')) mkdirSync(join(state.root, 'run')) state.identity = 'matched'; state.failure = ''; state.successes = 0; state.failures = 0 - state.signed.clear(); state.completed.clear(); state.stages = []; state.events = [] + state.signed.clear(); state.completed.clear(); state.stages = []; state.events = []; state.removals = [] state.environment = { DSH_DESKTOP_WINDOWS_CER_FILE: join(state.root, 'certificate.cer'), DSH_DESKTOP_WINDOWS_SIGNTOOL: 'forbidden-signer', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'fixture-pin', @@ -166,6 +181,48 @@ it('completes all three modes and releases each default-account batch only after expect(JSON.stringify(state.events)).not.toMatch(/fixture-pin|fixture-secret|fixture-container/u) }) +it('uses fresh real-file copies for 1, 2, 4 workers and reverses the second round', async () => { + await runWindowsSigningConcurrencyExperiment(['--execute', '--config-root', state.root, '--corpus', join(state.root, 'manifest.json')]) + expect(state.successes).toBe(6) + expect(state.signed.size).toBe(36) + expect(state.stages.some(stage => stage.startsWith('compile-'))).toBe(false) + expect(prepareSigningCorpus).toHaveBeenCalledOnce() + expect(state.events.filter(event => 'type' in event && event.type === 'experiment-batch-plan')).toEqual([ + expect.objectContaining({ round: 1, jobs: 1, hardwareConcurrency: 1 }), + expect.objectContaining({ round: 1, jobs: 2, hardwareConcurrency: 2 }), + expect.objectContaining({ round: 1, jobs: 4, hardwareConcurrency: 4 }), + expect.objectContaining({ round: 2, jobs: 4, hardwareConcurrency: 4 }), + expect.objectContaining({ round: 2, jobs: 2, hardwareConcurrency: 2 }), + expect.objectContaining({ round: 2, jobs: 1, hardwareConcurrency: 1 }), + ]) + expect(existsSync(lock())).toBe(false) +}) + +it('offers a separate four-job control with only one hardware request', async () => { + await runWindowsSigningConcurrencyExperiment(['--execute', '--config-root', state.root, '--rounds', '1', '--corpus', join(state.root, 'manifest.json'), '--pipeline-control']) + expect(state.successes).toBe(1) + expect(state.events).toContainEqual(expect.objectContaining({ type: 'experiment-batch-plan', jobs: 4, hardwareConcurrency: 1 })) +}) + +it('refuses corpus preparation failure before beginning a hardware attempt', async () => { + vi.mocked(prepareSigningCorpus).mockRejectedValueOnce(new Error('changed input')) + await expect(runWindowsSigningConcurrencyExperiment(['--execute', '--config-root', state.root, '--corpus', join(state.root, 'manifest.json')])).rejects.toThrow('changed input') + expect(beginWindowsSigningAttempt).not.toHaveBeenCalled() + expect(state.signed.size).toBe(0) +}) + +it('uses the stripping override only for public removal on a corpus copy', async () => { + const copy = join(state.root, 'copy.exe') + const stripTool = join(state.root, 'sdk-signtool.exe') + vi.mocked(prepareSigningCorpus).mockImplementationOnce(async (_manifest, _directory, remove) => { + await remove(copy) + throw new Error('stop before hardware') + }) + await expect(runWindowsSigningConcurrencyExperiment(['--execute', '--config-root', state.root, '--corpus', join(state.root, 'manifest.json'), '--strip-tool', stripTool])).rejects.toThrow('stop before hardware') + expect(state.removals).toEqual([{ executable: stripTool, args: ['remove', '/s', copy], environment: {} }]) + expect(beginWindowsSigningAttempt).not.toHaveBeenCalled() +}) + it.each(['primary', 'final', 'audit'] as const)('retains the batch interlock and stops later modes after %s failure', async (failure) => { state.failure = failure await expect(execute()).rejects.toThrow(failure === 'audit' ? 'Audit failed' : 'signature verification failed') diff --git a/apps/desktop/tests/windows-signing-corpus.spec.ts b/apps/desktop/tests/windows-signing-corpus.spec.ts new file mode 100644 index 0000000000..1849c2ea7a --- /dev/null +++ b/apps/desktop/tests/windows-signing-corpus.spec.ts @@ -0,0 +1,71 @@ +/** Corpus preparation touches only private copies; public signature inspection is simulated. */ +import { createHash } from 'node:crypto' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { prepareSigningCorpus } from './helpers/windows-signing-corpus.ts' +import { inspectWindowsRuntimeSignature } from '../scripts/windows-runtime-signature.mjs' + +vi.mock('../scripts/windows-runtime-signature.mjs', () => ({ inspectWindowsRuntimeSignature: vi.fn() })) +let root: string +let source: string +let manifest: string +const digest = (data: Buffer | string) => createHash('sha256').update(data).digest('hex') +beforeEach(async () => { + vi.resetAllMocks() + root = await mkdtemp(join(tmpdir(), 'signing-corpus-')) + source = join(root, 'original.dll') + manifest = join(root, 'manifest.json') + await writeFile(source, 'signed original') + await writeFile(manifest, JSON.stringify([{ path: source, sha256: digest('signed original') }])) + vi.mocked(inspectWindowsRuntimeSignature).mockResolvedValue({ status: 'NotSigned', timestamped: false, thumbprint: null }) +}) +afterEach(async () => { await rm(root, { recursive: true, force: true, maxRetries: 3 }) }) + +it('strips only a pinned private copy and preserves the original bytes', async () => { + vi.mocked(inspectWindowsRuntimeSignature).mockResolvedValueOnce({ status: 'Valid', timestamped: true, thumbprint: 'A' }) + const remove = vi.fn(async (path: string) => { expect(path).not.toBe(source); await writeFile(path, 'unsigned copy') }) + const files = await prepareSigningCorpus(manifest, join(root, 'copies'), remove, new AbortController().signal) + expect(remove).toHaveBeenCalledOnce() + expect(files).toEqual([{ path: join(root, 'copies', '0.dll'), sha256: digest('unsigned copy'), bytes: 13 }]) + expect(await readFile(source, 'utf8')).toBe('signed original') +}) + +it('accepts already unsigned input without invoking removal', async () => { + const remove = vi.fn() + const files = await prepareSigningCorpus(manifest, join(root, 'copies'), remove, new AbortController().signal) + expect(remove).not.toHaveBeenCalled() + expect(files[0]?.sha256).toBe(digest('signed original')) +}) + +it.each([[], {}, [{ path: 'relative', sha256: 'a'.repeat(64) }], [{ path: 'ignored', sha256: 'invalid' }]])('rejects malformed manifest %j', async (value) => { + await writeFile(manifest, JSON.stringify(value)) + const remove = vi.fn() + await expect(prepareSigningCorpus(manifest, join(root, 'copies'), remove, new AbortController().signal)).rejects.toThrow('Signing corpus') + expect(remove).not.toHaveBeenCalled() +}) + +it('rejects source content changed since the manifest was created', async () => { + await writeFile(source, 'changed source') + await expect(prepareSigningCorpus(manifest, join(root, 'copies'), vi.fn(), new AbortController().signal)).rejects.toThrow('pinned digest') + expect(inspectWindowsRuntimeSignature).not.toHaveBeenCalled() +}) + +it.each(['HashMismatch', 'NotTrusted'])('rejects invalid source signature %s without removal', async (status) => { + vi.mocked(inspectWindowsRuntimeSignature).mockResolvedValue({ status, timestamped: false, thumbprint: null }) + const remove = vi.fn() + await expect(prepareSigningCorpus(manifest, join(root, 'copies'), remove, new AbortController().signal)).rejects.toThrow('invalid signature') + expect(remove).not.toHaveBeenCalled() +}) + +it('rejects a copy whose signature removal did not finish', async () => { + vi.mocked(inspectWindowsRuntimeSignature).mockResolvedValue({ status: 'Valid', timestamped: true, thumbprint: 'A' }) + await expect(prepareSigningCorpus(manifest, join(root, 'copies'), vi.fn(), new AbortController().signal)).rejects.toThrow('must be unsigned') +}) + +it('stops before copying after cancellation', async () => { + const controller = new AbortController(); controller.abort(new Error('cancelled')) + await expect(prepareSigningCorpus(manifest, join(root, 'copies'), vi.fn(), controller.signal)).rejects.toThrow('cancelled') + expect(inspectWindowsRuntimeSignature).not.toHaveBeenCalled() +}) diff --git a/apps/desktop/tests/windows-signing-experiment.spec.ts b/apps/desktop/tests/windows-signing-experiment.spec.ts index 70b1404512..d14e9399eb 100644 --- a/apps/desktop/tests/windows-signing-experiment.spec.ts +++ b/apps/desktop/tests/windows-signing-experiment.spec.ts @@ -9,6 +9,36 @@ const observe = (operation: Promise) => operation.then( () => ({ ok: true as const }), (error: unknown) => ({ ok: false as const, error }), ) +it('drains four issued hardware calls after failure without dispatching a fifth', async () => { + const allStarted = barrier() + const release = barrier() + const failed = barrier() + const started: number[] = [] + const finished: number[] = [] + const outcome = observe(runSigningExperimentBatch([1, 2, 3, 4, 5, 6], { + jobs: 4, hardwareConcurrency: 4, + sign: async (item) => { + started.push(item) + if (started.length === 4) allStarted.resolve() + await allStarted.promise + if (item === 1) { failed.resolve(); throw new Error('hardware failure') } + await release.promise + finished.push(item) + }, + complete: async (item, sign) => { await sign(`staged-${item}`) }, + })) + let settled = false + void outcome.then(() => { settled = true }) + try { + await failed.promise + expect(started).toEqual([1, 2, 3, 4]) + expect(settled).toBe(false) + } finally { release.resolve() } + expect(await outcome).toEqual({ ok: false, error: new Error('hardware failure') }) + expect(finished.sort()).toEqual([2, 3, 4]) + expect(started).toEqual([1, 2, 3, 4]) +}) + it('finishes each item before beginning the next in serial mode', async () => { const events: string[] = [] await runSigningExperimentBatch([1, 2], { @@ -66,28 +96,28 @@ it('overlaps completion with the next signature while keeping hardware concurren expect(started).toEqual([1, 2, 3]) }) -it('allows two hardware requests but does not start a third item while both jobs are occupied', async () => { +it.each([2, 4] as const)('allows %i hardware requests but keeps later items queued until a job completes', async (limit) => { const bothSigning = barrier() const release = barrier() const started: number[] = [] let active = 0 let peak = 0 - const outcome = observe(runSigningExperimentBatch([1, 2, 3], { - jobs: 2, hardwareConcurrency: 2, + const outcome = observe(runSigningExperimentBatch(Array.from({ length: limit + 1 }, (_, index) => index + 1), { + jobs: limit, hardwareConcurrency: limit, sign: async () => { active++ peak = Math.max(peak, active) - if (active === 2) bothSigning.resolve() + if (active === limit) bothSigning.resolve() try { await release.promise } finally { active-- } }, complete: async (item, sign) => { started.push(item); await sign(`staged-${item}`) }, })) try { await bothSigning.promise - expect(started).toEqual([1, 2]) - expect(active).toBe(2) + expect(started).toEqual(Array.from({ length: limit }, (_, index) => index + 1)) + expect(active).toBe(limit) } finally { release.resolve(); expect(await outcome).toEqual({ ok: true }) } - expect(peak).toBe(2) + expect(peak).toBe(limit) expect(active).toBe(0) }) diff --git a/apps/desktop/windows-signing/README.i18n.yaml b/apps/desktop/windows-signing/README.i18n.yaml index 6446fe0ffc..de531aac7d 100644 --- a/apps/desktop/windows-signing/README.i18n.yaml +++ b/apps/desktop/windows-signing/README.i18n.yaml @@ -36,8 +36,8 @@ en: 6e8208d19ad510e5 zh: eee42e8938c0f603 /windows-signing-and-runner-operations/verification-limits: - en: e04b54cec106c2b7 - zh: 1941e4b0f0249c20 + en: cd4f2e91bb174352 + zh: 02b9db618b5aba74 /windows-signing-and-runner-operations/dev-note: en: b75c8458928b72b9 zh: 003ba4e17b4b5269 diff --git a/apps/desktop/windows-signing/README.md b/apps/desktop/windows-signing/README.md index b3b33756cf..d21bbbb986 100644 --- a/apps/desktop/windows-signing/README.md +++ b/apps/desktop/windows-signing/README.md @@ -106,7 +106,11 @@ Use `pnpm --dir apps/desktop run cache:windows-signatures --usage` to inspect st Real hardware and installation evidence is owned by the [Desktop qualification record](../tests/README.md) and [installed-update procedure](../tests/installed-update/README.md). This document does not authorize PIN retries, automatic interlock deletion, device initialization or changes to the accepted isolation policy. -For operator-approved concurrency experiments, run `node apps/desktop/tests/windows-signing-concurrency.perf.ts --execute --config-root --rounds 2`. Without `--execute`, it only prints usage. The diagnostic compares serial completion, two completion workers with one hardware request, and two concurrent hardware requests, using fresh copies of six synthetic PE files per batch; the second round reverses mode order. It uses the machine test preset, reads public identity first, holds the account signing-stage lock and retains one hardware-attempt interlock per batch. It stops dispatch on the first failure and drains issued work; up to two hardware calls may already be in flight. Only complete signature, timestamp and file verification permits normal interlock release. Records and probes remain under `.desktop-build/qualification/signing-concurrency`; probes are never executed and no release cache is used. Timings exclude probe preparation, lock waiting and package assembly. This experiment does not enable production concurrency or establish full-release performance. +For operator-approved concurrency experiments, run `node apps/desktop/tests/windows-signing-concurrency.perf.ts --execute --config-root --rounds 2`. Without `--execute`, it only prints usage. The default compares serial completion, two completion workers with one hardware request, and two concurrent hardware requests on six synthetic PE copies per batch. The second round reverses mode order. + +Add `--corpus ` to compare 1, 2 and 4 workers on real release files. The manifest is a nonempty JSON array of objects with an absolute `path` and its lowercase hexadecimal `sha256`. Preparation copies each pinned file, verifies its signature and removes any valid signature from the copy; invalid signatures or changed bytes stop preparation. Signature removal requires SignTool with `remove /s`; `--strip-tool ` overrides only that public preparation tool when the configured version lacks support. Hardware signing and timestamping still use the machine preset. Every batch uses fresh, byte-checked copies of the unsigned templates, preserving manifest order. `--pipeline-control` with a corpus instead runs four completion workers with one hardware request. Original files and release caches remain unchanged. + +The experiment uses the machine test preset, checks public identity, holds the account signing-stage lock and retains one hardware-attempt interlock per batch. The first failure stops dispatch and drains issued work; up to four hardware calls may already be in flight. Only complete signature, timestamp and file verification permits normal interlock release. Files are never executed. Records remain under `.desktop-build/qualification/signing-concurrency`, including batch and per-file elapsed times, batch endpoint host CPU counters, free memory and driver-process memory samples. These memory samples exclude child processes and do not establish peak memory. Timings exclude input preparation, lock waiting and package assembly. Replaying ready-made artifacts also excludes build dependencies between them; the experiment does not enable production concurrency or establish full-release performance. ## Dev Note diff --git a/apps/desktop/windows-signing/README.zh.md b/apps/desktop/windows-signing/README.zh.md index 7ac6cda265..1d505612ca 100644 --- a/apps/desktop/windows-signing/README.zh.md +++ b/apps/desktop/windows-signing/README.zh.md @@ -106,7 +106,11 @@ Windows 打包从 `.env.windows` 或所选预设读取 `DSH_DESKTOP_WINDOWS_PACK 真实硬件与安装证据由[Desktop 验证记录](../tests/README.zh.md)和[已安装更新流程](../tests/installed-update/README.zh.md)维护。本文档不授权 PIN 重试、自动删除 interlock、设备初始化或改变已接受的隔离策略。 -经操作人员授权后,可运行 `node apps/desktop/tests/windows-signing-concurrency.perf.ts --execute --config-root <打包配置目录的绝对路径> --rounds 2` 做并发实验。不加 `--execute` 只打印用法。诊断比较完全串行、两个完成任务共用单路硬件签名,以及两路硬件签名;每批使用六个合成 PE 文件的全新副本,第二轮反转模式顺序。实验读取机器 test preset,先核对公开身份,持有账户签名阶段锁,每批保留一条硬件尝试保护记录。首错停止派发并排空已发出的工作;最多可能已有两条硬件请求在途。只有全部签名、时间戳和文件验证成功才正常释放保护记录。记录与探针保留在 `.desktop-build/qualification/signing-concurrency`,探针从不执行,也不使用发布签名缓存。计时不含探针准备、等锁及包组装,实验不启用生产并发,也不代表完整发布性能。 +经操作人员授权后,可运行 `node apps/desktop/tests/windows-signing-concurrency.perf.ts --execute --config-root <打包配置目录的绝对路径> --rounds 2` 做并发实验。不加 `--execute` 只打印用法。默认比较完全串行、两个完成任务共用单路硬件签名,以及两路硬件签名,每批使用六个合成 PE 副本。第二轮反转模式顺序。 + +加上 `--corpus <清单 JSON 的绝对路径>` 后,改用真实发布文件比较 1、2、4 个并发任务。清单是非空 JSON 数组,每项包含绝对路径 `path` 及对应的小写十六进制 `sha256`。准备阶段复制哈希固定的文件,验证签名并仅移除副本上的有效签名;签名无效或字节变化会中止准备。移除签名要求 SignTool 支持 `remove /s`;配置中的版本不支持时,可用 `--strip-tool ` 仅覆盖这个公开准备工具。硬件签名和时间戳仍使用机器 preset。每批使用经过字节比对的全新无签名模板副本,保持清单顺序。配合语料使用 `--pipeline-control` 时,改为四个完成任务共用一个硬件请求。原文件和发布缓存保持不变。 + +实验读取机器 test preset,核对公开身份,持有账户签名阶段锁,每批保留一条硬件尝试保护记录。首错停止派发并排空已发出的工作;最多可能已有四条硬件请求在途。只有全部签名、时间戳和文件验证成功才正常释放保护记录。文件从不执行。记录保留在 `.desktop-build/qualification/signing-concurrency`,包含整批及单文件耗时、批次起止时的主机 CPU 计数、可用内存和驱动进程内存采样。内存采样不含子进程,不能用于确定峰值内存。计时不含输入准备、等锁和包组装。重放现成产物还排除了它们之间的构建依赖;实验不启用生产并发,也不代表完整发布性能。 ## 开发备注 diff --git a/scripts/windows-package-workflow.spec.ts b/scripts/windows-package-workflow.spec.ts index 4c707bc511..c481129739 100644 --- a/scripts/windows-package-workflow.spec.ts +++ b/scripts/windows-package-workflow.spec.ts @@ -1,7 +1,7 @@ import { execFileSync } from 'node:child_process' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { resolve } from 'node:path' +import { parse, resolve } from 'node:path' import yaml from 'js-yaml' import { expect, it } from 'vitest' @@ -202,35 +202,38 @@ it('scopes the optional temporary directory to installer building without changi it.skipIf(process.platform !== 'win32')('keeps the dependency store stable across run-specific pnpm homes', () => { const directory = mkdtempSync(resolve(tmpdir(), 'windows-package-store-')) try { + const workspace = resolve(directory, 'runner', 'repo') + const packageCache = resolve(directory, 'tool-cache', 'desktop') + const store = resolve(parse(directory).root, '.pnpm-store') const step = job.steps.find(entry => entry.name === 'Configure persistent pnpm store')! expect(job.steps.indexOf(step)).toBeLessThan(job.steps.findIndex(entry => entry.name === 'Install locked dependencies')) const script = resolve(directory, 'store.ps1') - writeFileSync(script, step.run!) + writeFileSync(script, "$ErrorActionPreference = 'Stop'\n" + step.run!) const outputs = ['first', 'second'].map((run) => { const environmentFile = resolve(directory, run + '.env') execFileSync('pwsh', ['-NoProfile', '-File', script], { - env: { ...process.env, GITHUB_WORKSPACE: 'D:\\runner\\_work\\repo\\repo', - PACKAGE_CACHE_NAMESPACE: '', DSH_DESKTOP_PACKAGE_CACHE_DIR: 'D:\\tool-cache\\desktop', + env: { ...process.env, GITHUB_WORKSPACE: workspace, + PACKAGE_CACHE_NAMESPACE: '', DSH_DESKTOP_PACKAGE_CACHE_DIR: packageCache, PNPM_HOME: resolve(directory, run, 'pnpm'), GITHUB_ENV: environmentFile }, stdio: 'pipe', }) return readFileSync(environmentFile, 'utf8').trim() }) expect(outputs.map(output => output.split(/\r?\n/u))).toEqual(Array.from({ length: 2 }, () => [ - 'DSH_DESKTOP_PACKAGE_CACHE_DIR=D:\\tool-cache\\desktop', 'PNPM_CONFIG_STORE_DIR=D:\\.pnpm-store', + `DSH_DESKTOP_PACKAGE_CACHE_DIR=${packageCache}`, `PNPM_CONFIG_STORE_DIR=${store}`, ])) const runNamespace = (namespace: string) => { const output = resolve(directory, 'namespace.env') rmSync(output, { force: true }) execFileSync('pwsh', ['-NoProfile', '-File', script], { - env: { ...process.env, GITHUB_WORKSPACE: 'D:\\runner\\_work\\repo\\repo', - PACKAGE_CACHE_NAMESPACE: namespace, DSH_DESKTOP_PACKAGE_CACHE_DIR: 'D:\\tool-cache\\desktop', GITHUB_ENV: output }, + env: { ...process.env, GITHUB_WORKSPACE: workspace, + PACKAGE_CACHE_NAMESPACE: namespace, DSH_DESKTOP_PACKAGE_CACHE_DIR: packageCache, GITHUB_ENV: output }, stdio: 'pipe', }) return readFileSync(output, 'utf8').trim().split(/\r?\n/u) } expect(runNamespace('perf-1')).toEqual([ - 'DSH_DESKTOP_PACKAGE_CACHE_DIR=D:\\tool-cache\\desktop\\perf-1', 'PNPM_CONFIG_STORE_DIR=D:\\.pnpm-store\\perf-1', + `DSH_DESKTOP_PACKAGE_CACHE_DIR=${resolve(packageCache, 'perf-1')}`, `PNPM_CONFIG_STORE_DIR=${resolve(store, 'perf-1')}`, ]) for (const invalid of ['../escape', 'Upper', 'has space', 'con', 'perf\n', 'a'.repeat(65)]) { expect(() => runNamespace(invalid)).toThrow() From 523ecd189bbf9a1134d1fffa20078f618c92f533 Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:18:07 +0800 Subject: [PATCH 27/46] docs(desktop): record signing concurrency qualification --- .../signing-concurrency/README.i18n.yaml | 25 ++++++++++ .../tests/signing-concurrency/README.md | 47 +++++++++++++++++++ .../tests/signing-concurrency/README.zh.md | 47 +++++++++++++++++++ apps/desktop/windows-signing/README.i18n.yaml | 4 +- apps/desktop/windows-signing/README.md | 2 +- apps/desktop/windows-signing/README.zh.md | 2 +- 6 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/tests/signing-concurrency/README.i18n.yaml create mode 100644 apps/desktop/tests/signing-concurrency/README.md create mode 100644 apps/desktop/tests/signing-concurrency/README.zh.md diff --git a/apps/desktop/tests/signing-concurrency/README.i18n.yaml b/apps/desktop/tests/signing-concurrency/README.i18n.yaml new file mode 100644 index 0000000000..ea5ef116aa --- /dev/null +++ b/apps/desktop/tests/signing-concurrency/README.i18n.yaml @@ -0,0 +1,25 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write apps/desktop/tests/signing-concurrency/README.md +/: + en: cde5fda2a2f4b53f + zh: 19dbeb4af20f26fa +/windows-signing-concurrency-qualification: + en: bd245a9d93c44d46 + zh: eb74f3edc9bb1f97 +/windows-signing-concurrency-qualification/summary: + en: 0d910efcaf9434da + zh: 0e6e179620e82713 +/windows-signing-concurrency-qualification/table-of-contents: + en: 498f69e40952088d + zh: 506c81b0f6879e9a +/windows-signing-concurrency-qualification/retest-on-another-machine: + en: b90878b9b5756cc9 + zh: e69d017153c10660 +/windows-signing-concurrency-qualification/measured-reference: + en: c2d5b47db6ce1e20 + zh: ca1182b064d63081 +/windows-signing-concurrency-qualification/dev-note: + en: b75c8458928b72b9 + zh: 003ba4e17b4b5269 diff --git a/apps/desktop/tests/signing-concurrency/README.md b/apps/desktop/tests/signing-concurrency/README.md new file mode 100644 index 0000000000..75295ce696 --- /dev/null +++ b/apps/desktop/tests/signing-concurrency/README.md @@ -0,0 +1,47 @@ +--- +description: "Windows signing concurrency measurements and retesting on replacement runners." +--- + +# Windows signing concurrency qualification + +English | [中文](README.zh.md) + +## Summary + +Use this record when choosing signing concurrency on a replacement Windows runner. Results apply to the measured host, token and software combination; repeat qualification after changing them. Production signing remains serial. + +## Table of Contents + +- [Retest on another machine](#retest-on-another-machine) +- [Measured reference](#measured-reference) + + + +## Retest on another machine + +1. Follow the [operator-approved experiment procedure](../../windows-signing/README.md#verification), using a hash-pinned corpus of real runtime, application and installer files. Compare the same inputs across modes; preserve originals and use isolated copies without signature-cache reuse. +2. Run `--corpus` with `--rounds 2` to compare 1/1, 2/2 and 4/4 completion workers/hardware requests, reversing the second round. Run the same corpus with `--pipeline-control --rounds 2` for the 4/1 control. Retain the procedure's first-failure stop and interlock handling. +3. Record the date, test and artifact versions, corpus count/bytes/largest file, CPU/RAM/disk, token/client/provider/SignTool versions, timestamp service, competing jobs and resource pressure. Keep both batch wall times and failure/retry counts; retain detailed output outside Git without credentials. Select a candidate only from completely verified runs, then validate it in the complete signed release workflow. Similar results favor fewer hardware requests; unexpected variation needs more rounds. + + + +## Measured reference + +On 2026-10-22, the experiment in PR #6675 used retained files from release run 39725804188, version `0.2.1-rc.2.20261022.50.2`. Each batch contained 148 runtime and 8 application/installer targets: 156 files, 849.2 MiB unsigned, largest 277.5 MiB. Deleted transient targets and repeated intermediate signing passes were excluded. + +The host had a Ryzen AI 9 HX 470 (12 cores/24 threads), 32 GiB RAM and a 1 TB UMIS RPJYJ1T24RLS1QWY disk. The operator identified the token as SafeNet 5110; the precise variant/firmware was not verified. Signing used SafeNet Authentication Client 10.9.4482.0, `eToken Base Cryptographic Provider` and the unchanged machine test preset's SignTool/timestamp service. Public signature removal alone used SDK 10.0.26100.0 SignTool. Mode-average host CPU was 7.6–8.9%, minimum sampled free RAM was 9.9 GiB, and no Runner.Worker was sampled; other Node processes shared the host. + +Times below are batch wall seconds, including signing, timestamp completion and all file verification. They exclude preparation, lock waiting, build, cache lookup, package assembly and upload. File-system caches were uncontrolled; these are not cold/hot full-release measurements. + +| Completion workers / hardware requests | Round 1 (s) | Round 2 (s) | Mean (s) | Reduction vs serial | +|---|---:|---:|---:|---:| +| 1 / 1 | 732.3 | 718.5 | 725.4 | — | +| 2 / 2 | 398.3 | 394.6 | 396.5 | 45.3% | +| 4 / 4 | 459.4 | 465.0 | 462.2 | 36.3% | +| 4 / 1, separate control | 427.7 | 428.3 | 428.0 | 41.0% | + +All 1,248 signatures, timestamps and final validations passed, with zero hardware failures/timeouts or timestamp retries. Original files and signature-cache payloads remained unchanged. The 2/2 mode was fastest; 4/4 was slower, while pipeline overlap alone delivered most of the observed benefit. This selects candidates for further release testing, not a production default or evidence of parallel operations inside the token. It does not establish a ten-minute complete release. + +## Dev Note + +None. diff --git a/apps/desktop/tests/signing-concurrency/README.zh.md b/apps/desktop/tests/signing-concurrency/README.zh.md new file mode 100644 index 0000000000..52c0a522d5 --- /dev/null +++ b/apps/desktop/tests/signing-concurrency/README.zh.md @@ -0,0 +1,47 @@ +--- +description: "Windows 签名并发实测结果与更换 runner 后的复测方法。" +--- + +# Windows 签名并发验收 + +[English](README.md) | 中文 + +## 摘要 + +更换 Windows runner 后,使用本记录选择签名并发参数。结果仅适用于实测的主机、UKey 和软件组合;更换后需重新验收。生产签名仍为串行。 + +## 目录 + +- [在另一台机器上复测](#retest-on-another-machine) +- [实测参考](#measured-reference) + + + +## 在另一台机器上复测 + +1. 按[经操作人员授权的实验流程](../../windows-signing/README.zh.md#verification),使用哈希固定的真实 runtime、应用和安装器文件集。各模式使用相同输入,保留原件,使用隔离副本且不复用签名缓存。 +2. 使用 `--corpus` 和 `--rounds 2` 比较完成任务数/硬件请求数为 1/1、2/2、4/4 的模式,第二轮反转顺序。对相同文件集使用 `--pipeline-control --rounds 2` 测量 4/1 对照组。保留流程中的首错停止和 interlock 处理。 +3. 记录日期、测试及产物版本、文件数/总大小/最大文件、CPU/内存/磁盘、UKey/客户端/provider/SignTool 版本、时间戳服务、并行任务和资源压力。保留两轮整批墙钟时间及失败/重试次数;详细输出保存在 Git 外且不含凭据。仅从全部验证通过的结果中选择候选参数,再用完整签名发布流程验收。结果接近时优先减少硬件请求;波动异常时增加轮次。 + + + +## 实测参考 + +2026-10-22,PR #6675 中的实验使用发布 run 39725804188 保留的文件,版本为 `0.2.1-rc.2.20261022.50.2`。每批包含 148 个 runtime 和 8 个应用/安装器签名目标:共 156 个文件,无签名总大小 849.2 MiB,最大文件 277.5 MiB。不含已删除的临时目标及中间产物的重复签名调用。 + +主机使用 Ryzen AI 9 HX 470(12 核/24 线程)、32 GiB 内存和 1 TB UMIS RPJYJ1T24RLS1QWY 磁盘。操作人员确认 UKey 为 SafeNet 5110,未核实具体变体/固件。签名使用 SafeNet Authentication Client 10.9.4482.0、`eToken Base Cryptographic Provider`,以及未变更的机器 test preset 中的 SignTool/时间戳服务。仅公开移除签名使用 SDK 10.0.26100.0 SignTool。各模式主机平均 CPU 为 7.6–8.9%,采样可用内存最低 9.9 GiB,未采样到 Runner.Worker;另有 Node 进程共享主机。 + +下表为整批墙钟秒数,包含签名、时间戳完成及全部文件验证。不含准备、等锁、构建、缓存查询、包组装和上传。文件系统缓存未受控,因此不是完整发布的冷/热缓存测量。 + +| 完成任务数 / 硬件请求数 | 第 1 轮(秒) | 第 2 轮(秒) | 平均(秒) | 相对串行减少 | +|---|---:|---:|---:|---:| +| 1 / 1 | 732.3 | 718.5 | 725.4 | — | +| 2 / 2 | 398.3 | 394.6 | 396.5 | 45.3% | +| 4 / 4 | 459.4 | 465.0 | 462.2 | 36.3% | +| 4 / 1,单独对照组 | 427.7 | 428.3 | 428.0 | 41.0% | + +共 1,248 次签名、时间戳及最终验证全部通过,硬件失败/超时及时间戳重试均为零。原文件和签名缓存内容保持不变。2/2 模式最快,4/4 更慢;仅流水线重叠已获得大部分实测收益。这些结果用于选择后续发布测试的候选参数,不是生产默认值,也不能证明 UKey 内部并行执行。它们不能证明完整发布达到十分钟。 + +## 开发备注 + +无。 diff --git a/apps/desktop/windows-signing/README.i18n.yaml b/apps/desktop/windows-signing/README.i18n.yaml index de531aac7d..edb31b9766 100644 --- a/apps/desktop/windows-signing/README.i18n.yaml +++ b/apps/desktop/windows-signing/README.i18n.yaml @@ -36,8 +36,8 @@ en: 6e8208d19ad510e5 zh: eee42e8938c0f603 /windows-signing-and-runner-operations/verification-limits: - en: cd4f2e91bb174352 - zh: 02b9db618b5aba74 + en: 53f1fe23f54af84e + zh: d219606367ff2d72 /windows-signing-and-runner-operations/dev-note: en: b75c8458928b72b9 zh: 003ba4e17b4b5269 diff --git a/apps/desktop/windows-signing/README.md b/apps/desktop/windows-signing/README.md index d21bbbb986..579ccc8ae1 100644 --- a/apps/desktop/windows-signing/README.md +++ b/apps/desktop/windows-signing/README.md @@ -104,7 +104,7 @@ Use `pnpm --dir apps/desktop run cache:windows-signatures --usage` to inspect st ## Verification limits -Real hardware and installation evidence is owned by the [Desktop qualification record](../tests/README.md) and [installed-update procedure](../tests/installed-update/README.md). This document does not authorize PIN retries, automatic interlock deletion, device initialization or changes to the accepted isolation policy. +Signing performance evidence and replacement-runner retesting are owned by the [signing concurrency qualification](../tests/signing-concurrency/README.md). Update and installation evidence is owned by the [Desktop qualification record](../tests/README.md) and [installed-update procedure](../tests/installed-update/README.md). This document does not authorize PIN retries, automatic interlock deletion, device initialization or changes to the accepted isolation policy. For operator-approved concurrency experiments, run `node apps/desktop/tests/windows-signing-concurrency.perf.ts --execute --config-root --rounds 2`. Without `--execute`, it only prints usage. The default compares serial completion, two completion workers with one hardware request, and two concurrent hardware requests on six synthetic PE copies per batch. The second round reverses mode order. diff --git a/apps/desktop/windows-signing/README.zh.md b/apps/desktop/windows-signing/README.zh.md index 1d505612ca..65996941cd 100644 --- a/apps/desktop/windows-signing/README.zh.md +++ b/apps/desktop/windows-signing/README.zh.md @@ -104,7 +104,7 @@ Windows 打包从 `.env.windows` 或所选预设读取 `DSH_DESKTOP_WINDOWS_PACK ## 验证范围 -真实硬件与安装证据由[Desktop 验证记录](../tests/README.zh.md)和[已安装更新流程](../tests/installed-update/README.zh.md)维护。本文档不授权 PIN 重试、自动删除 interlock、设备初始化或改变已接受的隔离策略。 +签名性能证据与更换 runner 后的复测由[签名并发验收](../tests/signing-concurrency/README.zh.md)维护。更新与安装证据由[Desktop 验证记录](../tests/README.zh.md)和[已安装更新流程](../tests/installed-update/README.zh.md)维护。本文档不授权 PIN 重试、自动删除 interlock、设备初始化或改变已接受的隔离策略。 经操作人员授权后,可运行 `node apps/desktop/tests/windows-signing-concurrency.perf.ts --execute --config-root <打包配置目录的绝对路径> --rounds 2` 做并发实验。不加 `--execute` 只打印用法。默认比较完全串行、两个完成任务共用单路硬件签名,以及两路硬件签名,每批使用六个合成 PE 副本。第二轮反转模式顺序。 From 6c5d7c918a8aafee2aca6b085ffad355ddec4ea3 Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:08:45 +0800 Subject: [PATCH 28/46] fix(desktop): address packaging review follow-ups --- .github/workflows/macos-package.yml | 1 + apps/desktop/packaging/README.i18n.yaml | 16 +++++++------- apps/desktop/packaging/README.md | 8 +++---- apps/desktop/packaging/README.zh.md | 8 +++---- .../scripts/desktop-package-environment.d.mts | 1 + .../scripts/desktop-package-environment.mjs | 10 ++++++--- .../scripts/windows-build-workspace.mjs | 3 ++- .../desktop/tests/desktop-build-paths.spec.ts | 21 ++++++------------- .../tests/desktop-package-environment.spec.ts | 5 +++++ scripts/macos-package-workflow.spec.ts | 1 + 10 files changed, 39 insertions(+), 35 deletions(-) diff --git a/.github/workflows/macos-package.yml b/.github/workflows/macos-package.yml index 8209910269..eeb451266e 100644 --- a/.github/workflows/macos-package.yml +++ b/.github/workflows/macos-package.yml @@ -151,5 +151,6 @@ jobs: name: macos-arm64-${{ steps.settings.outputs.mode }}-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }} include-hidden-files: true path: ${{ steps.retained.outputs.directory }}/* + compression-level: 0 if-no-files-found: error retention-days: 14 diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index 2d47bde630..9b36383e84 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -24,11 +24,11 @@ en: 5b4f0f80a117f202 zh: ff05be196c181087 /desktop-packaging/windows-actions-packaging: - en: cb554f51438381b1 - zh: f7a40d65fc5a57fc + en: 364223725374d9ac + zh: c4d4cc58d7cbafbd /desktop-packaging/macos-self-hosted-packaging: - en: f4668c5d616ba6c9 - zh: 2416d68e55ed48c2 + en: bb1dcdfef3f3a113 + zh: 9c151800a61e2505 /desktop-packaging/unsigned-windows-test-installer: en: 6843a0fdc03ead58 zh: 957f92fc8126e23e @@ -36,8 +36,8 @@ en: 2feaa0412247d81f zh: 461dde8e13fd4734 /desktop-packaging/macos-release-signing-and-diagnostics: - en: be48e5f7205fc058 - zh: 3be2e0bf2180d413 + en: 29fa6bd8919f3ec0 + zh: 9e31e037fa3d9378 /desktop-packaging/directory-builds-and-preparation: en: 5860c306375ddd63 zh: a76e3cdbbe25c79e @@ -45,8 +45,8 @@ en: b42f103358d0dfd7 zh: a534164ad9eb00da /desktop-packaging/runtime-file-selection-and-installation-checks: - en: 3d6a375108c6b3d7 - zh: 5fce0057313b3527 + en: 10c919aa787c1884 + zh: e46c4838793224dd /desktop-packaging/desktop-runtime-preparation: en: 59783a64552d4464 zh: d7876d51b4015578 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index a31c55cef8..5dd4f314e3 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -115,7 +115,7 @@ Select the workflow in Actions, choose the source branch, preset, signing mode a Open the completed run in Actions and download its Artifacts. `windows-x64-signed---` and `windows-x64-unsigned---` contain the corresponding EXE; `windows-package-logs--` contains packaging diagnostics. The upload steps are `Retain verified signed installer`, `Retain verified unsigned installer`, and `Retain packaging diagnostics`; their step durations and timestamped logs show upload time. These artifacts are not a Release or download-site publication. -For path-sensitive Windows build measurements, `build_root` optionally selects an absolute directory outside existing Git checkouts. Each run clones the validated source commit into a new `windows-package--/source` directory; ignored files and previous build outputs are not copied. Only the packaging process uses a sibling run-owned `temp` directory through `TEMP` and `TMP`; Actions steps and post-job hooks retain their original temporary directories. Dependency and signing caches retain their existing locations. Successful runs remove only their owned checkout after artifact uploads; failed runs retain it for inspection. Leave the input empty to build in the original Actions checkout. Include checkout creation and cleanup in the complete Job timing. +For path-sensitive Windows build measurements, `build_root` optionally selects an absolute directory outside existing Git checkouts. Each run clones the validated source commit into a new `windows-package--/source` directory; ignored files and previous build outputs are not copied. Only the packaging process uses a sibling run-owned `temp` directory through `TEMP` and `TMP`; Actions steps and post-job hooks retain their original temporary directories. Dependency and signing caches retain their existing locations. Successful runs remove only their owned checkout after artifact uploads, retrying transient filesystem failures up to three times with 100 ms increasing delays; persistent cleanup errors fail the Job and may leave a partially removed directory. Failed builds retain their checkout for inspection. Leave the input empty to build in the original Actions checkout. Include checkout creation and cleanup in the complete Job timing. For cache measurements, `cache_namespace` selects a retained subdirectory for both dependency stores and upstream downloads; leave it empty for normal shared caches. Use a new name for a cold sample and repeat that name for warm samples. Names contain 1–64 lowercase letters, digits or hyphens, start with a letter or digit, and cannot be Windows device names. This does not reset the separate signature cache or OS file cache; report those states and Job start-to-completion time alongside phase timings and artifact size. Before dependency installation, the workflow records the Node version, available CPU parallelism, logical CPU count, and total/free memory. @@ -127,7 +127,7 @@ Installer uploads store the already-compressed release files without ZIP recompr ## macOS self-hosted packaging -The `Package Desktop · macOS` [manual workflow](../../../.github/workflows/macos-package.yml) targets an Apple Silicon runner labeled `dsh-mac-package-trial`. It selects `test` or `production`, resolves `build_version=auto` from the UTC date, run number and attempt, and uses the existing signing, notarization and packaged-runtime checks. Successful DMG/ZIP files and diagnostic logs are retained for 14 days; this workflow does not publish a release. The operator must register the runner and verify a complete Actions run before relying on it. The `zip_compression_level` dropdown defaults to `3` and also offers `7`; it overrides the machine preset for this run and affects only the ZIP and automatic-update downloads, not the DMG. +The `Package Desktop · macOS` [manual workflow](../../../.github/workflows/macos-package.yml) targets an Apple Silicon runner labeled `dsh-mac-package-trial`. It selects `test` or `production`, resolves `build_version=auto` from the UTC date, run number and attempt, and uses the existing signing, notarization and packaged-runtime checks. Installer upload disables redundant ZIP compression; diagnostic logs retain normal compression. Successful DMG/ZIP files and diagnostic logs are retained for 14 days; this workflow does not publish a release. The operator must register the runner and verify a complete Actions run before relying on it. The `zip_compression_level` dropdown defaults to `3` and also offers `7`; it overrides the machine preset for this run and affects only the ZIP and automatic-update downloads, not the DMG. Run `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/dsh-ci"` to prepare private `runner/`, `packaging/{presets,secrets,certificates}` and `cache/macos-signatures` directories without overwriting existing files; append `--check` to inspect them. Fill the [preset](../config/macos-packaging/test.env.example) and [signing](../config/macos-packaging/macos-signing.env.example) templates outside the checkout. Set secret and certificate files to mode `0600`. `DSH_PACKAGING_CONFIG_DIR` selects the absolute `packaging` directory and `DSH_PACKAGING_PRESET` selects its preset; missing files fail without falling back to `.env.macos`. Signed and preparation-only Mac builds read `secrets/macos-signing.env`; relative credential paths resolve beside that file. Public presets reject signing credentials, and signing files cannot override public settings. Local unsigned Mac builds retain their configuration-free behavior. @@ -189,7 +189,7 @@ Mac package commands print `DESKTOP_PACKAGING_RECORD` with a unique directory un 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`: +Mac packaging reads these tuning fields from `.env.macos`: | Setting | Default | Scope | |---|---|---| @@ -265,7 +265,7 @@ When extraction fails, the installer writes a report with the 7-Zip result and i Windows packaging fixes the 7-Zip filter to `BCJ` for compatibility with the bundled NSIS decoder. This preserves ARM64 binaries carried by dependencies in x64 installers; automatic ARM64 filtering produces entries that this decoder cannot extract. -`DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL` in the Windows dotenv or machine preset selects installer archive compression from `0` to `9`. If omitted, the existing `ELECTRON_BUILDER_COMPRESSION_LEVEL` environment override remains effective, otherwise the default is `3` for both test and production. The manual Windows workflow's `compression_level` dropdown defaults to `3` and also offers `7`, matching the Mac ZIP choices; it overrides the file setting for that run. Direct local builds retain explicit machine settings. Lower levels trade installer size for packaging time; qualify the complete signed workflow and artifact size before changing a release preset. +`DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL` in the Windows dotenv or machine preset selects installer archive compression from `0` to `9`. If omitted, the existing `ELECTRON_BUILDER_COMPRESSION_LEVEL` environment override remains effective, otherwise the default is `3` for both test and production. The manual Windows workflow's `compression_level` dropdown defaults to `3` and also offers `7`, matching the Mac ZIP choices; it overrides the file setting for that run. Direct local builds retain explicit machine settings. Nonempty workflow compression overrides reject the other target platform. Lower levels trade installer size for packaging time; qualify the complete signed workflow and artifact size before changing a release preset. NSIS removes its temporary extraction tree during installation, before the completion page or an automatic launch. The installed production packages remain ordinary files; startup does not extract them again. Installation still writes the complete application tree. diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index 86158c006e..fa604b2370 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -115,7 +115,7 @@ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-s 在 Actions 打开完成的运行并下载 Artifacts。`windows-x64-signed---` 与 `windows-x64-unsigned---` 包含对应 EXE;`windows-package-logs--` 包含打包诊断。上传步骤名为 `Retain verified signed installer`、`Retain verified unsigned installer` 和 `Retain packaging diagnostics`;步骤耗时与带时间戳的日志显示上传用时。这些产物不等于发布 Release 或下载站内容。 -测量 Windows 构建路径对性能的影响时,可用 `build_root` 指定现有 Git checkout 之外的绝对目录。每轮任务将已验证的源码提交克隆到新的 `windows-package--/source` 目录;不会复制忽略文件或此前的构建输出。仅打包进程通过 `TEMP` 和 `TMP` 使用同轮所属的同级 `temp` 目录;Actions 步骤及 Job 后置钩子保留原临时目录。依赖缓存和签名缓存沿用原有位置。成功任务在产物上传后仅删除本轮所属的 checkout;失败任务保留该目录供检查。输入留空时使用原 Actions checkout。完整 Job 耗时须包含 checkout 创建和清理。 +测量 Windows 构建路径对性能的影响时,可用 `build_root` 指定现有 Git checkout 之外的绝对目录。每轮任务将已验证的源码提交克隆到新的 `windows-package--/source` 目录;不会复制忽略文件或此前的构建输出。仅打包进程通过 `TEMP` 和 `TMP` 使用同轮所属的同级 `temp` 目录;Actions 步骤及 Job 后置钩子保留原临时目录。依赖缓存和签名缓存沿用原有位置。成功任务在产物上传后仅删除本轮所属的 checkout,临时文件系统错误最多重试三次,等待时间按 100 ms 递增;持续清理失败会使 Job 失败,并可能留下部分删除的目录。构建失败时保留 checkout 供检查。输入留空时使用原 Actions checkout。完整 Job 耗时须包含 checkout 创建和清理。 测量缓存时,`cache_namespace` 为两个依赖 store 和上游下载选择保留的子目录;留空使用正常共享缓存。新名称用于冷缓存采样,再次使用同名用于热缓存采样。名称由 1–64 个小写字母、数字或连字符组成,以字母或数字开头,且不能为 Windows 设备名。这不会重置独立的签名缓存或操作系统文件缓存;应将其状态、Job 开始至完成的总耗时与阶段耗时和产物体积一并报告。workflow 在依赖安装前记录 Node 版本、可用 CPU 并行数、逻辑 CPU 数及总内存和空闲内存。 @@ -127,7 +127,7 @@ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-s ## macOS 自托管打包 -`Package Desktop · macOS` [手动工作流](../../../.github/workflows/macos-package.yml) 使用带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner。它选择 `test` 或 `production`,以 UTC 日期、运行编号和重试次数解析 `build_version=auto`,并复用现有签名、公证和打包运行时检查。成功的 DMG/ZIP 与诊断日志保留 14 天;工作流不发布 release。操作人员须注册 runner,并验收完整 Actions 运行后再依赖该流程。 `zip_compression_level` 下拉选项默认为 `3`,也可选择 `7`;本次运行以该选项覆盖机器预设,只影响 ZIP 和自动升级下载,不影响 DMG。 +`Package Desktop · macOS` [手动工作流](../../../.github/workflows/macos-package.yml) 使用带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner。它选择 `test` 或 `production`,以 UTC 日期、运行编号和重试次数解析 `build_version=auto`,并复用现有签名、公证和打包运行时检查。安装包上传关闭重复 ZIP 压缩;诊断日志保留普通压缩。成功的 DMG/ZIP 与诊断日志保留 14 天;工作流不发布 release。操作人员须注册 runner,并验收完整 Actions 运行后再依赖该流程。 `zip_compression_level` 下拉选项默认为 `3`,也可选择 `7`;本次运行以该选项覆盖机器预设,只影响 ZIP 和自动升级下载,不影响 DMG。 运行 `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/dsh-ci"` 创建私有 `runner/`、`packaging/{presets,secrets,certificates}` 和 `cache/macos-signatures` 目录,不覆盖已有文件;追加 `--check` 仅检查。在 checkout 外填写 [preset](../config/macos-packaging/test.env.example) 和[签名](../config/macos-packaging/macos-signing.env.example)模板,将秘密和证书文件权限设为 `0600`。`DSH_PACKAGING_CONFIG_DIR` 指定 `packaging` 的绝对目录,`DSH_PACKAGING_PRESET` 选择其中的 preset;缺少文件立即失败,不回退到 `.env.macos`。Mac 签名和仅准备构建读取 `secrets/macos-signing.env`,相对凭据路径以该文件所在目录为基准。公开 preset 拒绝签名凭据,签名文件不能覆盖公开配置。本地未签名 Mac 构建保留无需配置的行为。 @@ -189,7 +189,7 @@ Mac 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `apps/desktop/.desktop- App 和 DMG 公证分别记录 submission ID,以及独立的 `notarytool:upload:*` 和 `notarytool:wait:*` 耗时。上传使用 `submit --no-wait`,包含认证、本地校验、传输和服务端受理,并非纯传输时间。等待从该命令返回后开始,包含轮询及剩余的 Apple 处理时间;Apple 可能在上传提交命令返回前已开始处理。日志保留 Apple 状态与诊断信息,包括拒绝结果;签名检查、接受状态校验和 stapling 仍由 `@electron/notarize` 负责。仅生成目录的打包命令也使用同一条可计时的 App 公证路径。各子进程输出仍实时显示在终端,阶段失败及嵌套错误保留在事件日志中。两个平台均记录父进程打包失败,并在终端显示脱敏诊断,包括代理恢复操作指引。仅检查配置的 `check:package` 命令不创建运行日志。 -Mac 打包从 `.env.macos` 读取三个调优字段: +Mac 打包从 `.env.macos` 读取以下调优字段: | 设置 | 默认值 | 作用范围 | |---|---|---| @@ -265,7 +265,7 @@ Windows 安装器在启动时和选定目标目录后检查应用是否正在运 Windows 打包将 7-Zip 过滤器固定为 `BCJ`,以兼容内置的 NSIS 解码器。这样可以保留 x64 安装包中由依赖携带的 ARM64 二进制文件;自动 ARM64 过滤会生成该解码器无法解压的条目。 -Windows dotenv 或机器 preset 中的 `DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL` 选择 `0` 至 `9` 的安装包归档压缩等级。省略时,现有 `ELECTRON_BUILDER_COMPRESSION_LEVEL` 环境覆盖仍然生效,否则 test 和 production 均默认为 `3`。手动 Windows workflow 的 `compression_level` 下拉默认选 `3`,也提供 `7`,与 Mac ZIP 选项一致;它对本轮覆盖文件中的设置。直接本地打包保留显式机器配置。较低等级以安装包体积换取打包时间;修改发布 preset 前应验证完整签名 workflow 和产物体积。 +Windows dotenv 或机器 preset 中的 `DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL` 选择 `0` 至 `9` 的安装包归档压缩等级。省略时,现有 `ELECTRON_BUILDER_COMPRESSION_LEVEL` 环境覆盖仍然生效,否则 test 和 production 均默认为 `3`。手动 Windows workflow 的 `compression_level` 下拉默认选 `3`,也提供 `7`,与 Mac ZIP 选项一致;它对本轮覆盖文件中的设置。直接本地打包保留显式机器配置。非空的 workflow 压缩覆盖值会拒绝用于另一目标平台。较低等级以安装包体积换取打包时间;修改发布 preset 前应验证完整签名 workflow 和产物体积。 NSIS 在安装阶段清理临时解压目录,完成后才显示完成页或自动启动应用。已安装的生产依赖保持为普通文件;启动时不会再次解压。安装仍会写入完整的应用目录树。 diff --git a/apps/desktop/scripts/desktop-package-environment.d.mts b/apps/desktop/scripts/desktop-package-environment.d.mts index 071265fbd3..2b04398d11 100644 --- a/apps/desktop/scripts/desktop-package-environment.d.mts +++ b/apps/desktop/scripts/desktop-package-environment.d.mts @@ -11,6 +11,7 @@ export function localMacOSPackageEnvironment(environment?: NodeJS.ProcessEnv): N * Read a machine preset when selected, otherwise the target's required dotenv; no ambient release fallback. * PACKAGE_TEST_RELEASE_ID overrides the test preset only; blank retains its configured ID. * PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL selects 3 or 7 for Mac workflow builds, overriding the file setting. + * Nonempty workflow compression overrides reject the other platform. * PACKAGE_WINDOWS_COMPRESSION_LEVEL selects 3 or 7 for Windows workflow builds; blank retains the file setting. * @param platform Target platform. * @param environment Parent environment, retained only for unrelated build tools. diff --git a/apps/desktop/scripts/desktop-package-environment.mjs b/apps/desktop/scripts/desktop-package-environment.mjs index 6ec555857d..e6e4f2f071 100644 --- a/apps/desktop/scripts/desktop-package-environment.mjs +++ b/apps/desktop/scripts/desktop-package-environment.mjs @@ -37,6 +37,7 @@ export function localMacOSPackageEnvironment(environment = process.env) { /** * Read a machine preset when selected, otherwise the target's required dotenv; no ambient release fallback. * PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL selects 3 or 7 for Mac workflow builds, overriding the file setting. + * Nonempty workflow compression overrides reject the other platform. * PACKAGE_TEST_RELEASE_ID overrides the test preset only; blank retains its configured ID. * PACKAGE_WINDOWS_COMPRESSION_LEVEL selects 3 or 7 for Windows workflow builds; blank retains the file setting. * @param {'win32' | 'darwin'} platform Target platform. @@ -52,6 +53,11 @@ export function loadDesktopPackageEnvironment(platform, environment = process.en throw new Error('desktop package: PACKAGE_WINDOWS_COMPRESSION_LEVEL requires Windows and a choice of 3 or 7') } } + const zipCompression = environment.PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL + if (zipCompression !== undefined) { + if (platform !== 'darwin') throw new Error('desktop package: PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL requires macOS') + if (!['3', '7'].includes(zipCompression)) throw new Error('desktop package: workflow ZIP compression must be 3 or 7') + } const preset = environment.DSH_PACKAGING_PRESET let path = join(appRoot, platform === 'win32' ? '.env.windows' : '.env.macos') if (preset !== undefined) { @@ -78,9 +84,7 @@ export function loadDesktopPackageEnvironment(platform, environment = process.en } } if (compression !== undefined && compression !== '') settings.DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL = compression - const zipCompression = environment.PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL - if (platform === 'darwin' && zipCompression !== undefined) { - if (!['3', '7'].includes(zipCompression)) throw new Error('desktop package: workflow ZIP compression must be 3 or 7') + if (zipCompression !== undefined) { settings.DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL = zipCompression } const releaseId = environment.PACKAGE_TEST_RELEASE_ID?.trim() diff --git a/apps/desktop/scripts/windows-build-workspace.mjs b/apps/desktop/scripts/windows-build-workspace.mjs index c4048bf377..977dda6618 100644 --- a/apps/desktop/scripts/windows-build-workspace.mjs +++ b/apps/desktop/scripts/windows-build-workspace.mjs @@ -65,6 +65,7 @@ export function prepareWindowsBuildWorkspace(options) { /** * Remove only the matching run-owned directory after successful artifact upload. + * Transient filesystem failures retry up to three times; persistent failures propagate. * @param {{sourceDirectory: string, buildRoot: string, runId: string, runAttempt: string, sourceSha: string}} options - Original source and run identity. * @returns {void} */ @@ -75,7 +76,7 @@ export function removeWindowsBuildWorkspace(options) { if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 8192 || readFileSync(marker, 'utf8') !== ownership(options, paths)) { throw new Error('Packaging workspace ownership does not match this run') } - rmSync(paths.owner, { recursive: true }) + rmSync(paths.owner, { recursive: true, maxRetries: 3, retryDelay: 100 }) } if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { diff --git a/apps/desktop/tests/desktop-build-paths.spec.ts b/apps/desktop/tests/desktop-build-paths.spec.ts index f597c1626a..f9891ebb9c 100644 --- a/apps/desktop/tests/desktop-build-paths.spec.ts +++ b/apps/desktop/tests/desktop-build-paths.spec.ts @@ -39,7 +39,6 @@ describe('desktop build paths', () => { it('shares download and package caches outside target output directories', () => { const arm64 = desktopTargetBuildPaths('mac-arm64') const x64 = desktopTargetBuildPaths('mac-x64') - expect(arm64.pnpmStore).toBe(x64.pnpmStore) expect(arm64.downloads).toBe(x64.downloads) expect(arm64.downloads).not.toContain(`${sep}targets${sep}`) expect(arm64.pnpmStore).toBe(x64.pnpmStore) @@ -53,6 +52,12 @@ describe('desktop build paths', () => { const paths = resolveDesktopTargetBuildPaths(environment, 'win32', 'x64') expect(paths).toEqual({ ...defaults, downloads: join(cache, 'downloads'), pnpmStore: join(cache, 'pnpm-store') }) expect(desktopTargetBuildPaths('mac-arm64', environment).pnpmStore).toBe(paths.pnpmStore) + for (const target of ['mac-arm64', 'mac-x64', 'win-x64'] as const) { + const configured = desktopTargetBuildPaths(target, environment) + expect(configured.downloads).toBe(join(cache, 'downloads')) + expect(configured.pnpmStore).toBe(join(cache, 'pnpm-store')) + expect(configured.root).not.toContain(cache) + } expect(defaults.pnpmStore).toBe(resolve(defaults.root, '../..', 'pnpm-store')) }) @@ -87,17 +92,3 @@ describe('desktop build paths', () => { expect(() => desktopTargetBuildPaths('linux-x64' as 'mac-x64')).toThrow(/unsupported target/u) }) }) - -it('keeps caches outside the disposable target directories when explicitly configured', () => { - const cache = join(process.cwd(), 'external-build-cache') - const environment = { DSH_DESKTOP_PACKAGE_CACHE_DIR: cache } - for (const target of ['mac-arm64', 'mac-x64', 'win-x64'] as const) { - const paths = desktopTargetBuildPaths(target, environment) - expect(paths.downloads).toBe(join(cache, 'downloads')) - expect(paths.pnpmStore).toBe(join(cache, 'pnpm-store')) - expect(paths.root).not.toContain(cache) - } - for (const value of ['', 'relative-cache']) { - expect(() => desktopTargetBuildPaths('mac-arm64', { DSH_DESKTOP_PACKAGE_CACHE_DIR: value })).toThrow('must be a nonempty absolute directory') - } -}) diff --git a/apps/desktop/tests/desktop-package-environment.spec.ts b/apps/desktop/tests/desktop-package-environment.spec.ts index 30e07ba3ae..701a9e72dc 100644 --- a/apps/desktop/tests/desktop-package-environment.spec.ts +++ b/apps/desktop/tests/desktop-package-environment.spec.ts @@ -376,6 +376,11 @@ it.each(['3', '7'])('applies the workflow ZIP choice %s over the Mac file settin }) }) +it.each(['3', '7'])('rejects Mac workflow ZIP choice %s on Windows before reading settings', (level) => { + expect(() => loadDesktopPackageEnvironment('win32', { PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL: level }, 'missing-settings')) + .toThrow('PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL requires macOS') +}) + it.each(['', '0', '9', 'invalid'])('rejects unsupported workflow ZIP choice %j', async (level) => { await withDirectory(async (directory) => { await writeFile(join(directory, '.env.macos'), '') diff --git a/scripts/macos-package-workflow.spec.ts b/scripts/macos-package-workflow.spec.ts index d92205573a..6d806b0af6 100644 --- a/scripts/macos-package-workflow.spec.ts +++ b/scripts/macos-package-workflow.spec.ts @@ -38,6 +38,7 @@ it('confines signed Mac packaging to manual dispatch and a dedicated Apple Silic expect(uploads[0]?.if).toBe("always() && steps.checkout.outcome == 'success'") expect(uploads[1]?.if).toBe('success()') expect(uploads[1]?.with?.path).toBe('${{ steps.retained.outputs.directory }}/*') + expect(uploads[1]?.with?.['compression-level']).toBe(0) }) it('defaults to notarization and labels the explicit debugging option', () => { From 5a45e0a448feceeb3faf4f911ea5633d580a3959 Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:37:19 +0800 Subject: [PATCH 29/46] fix(desktop): clarify signing prerequisites and portable fixtures --- apps/desktop/packaging/README.i18n.yaml | 4 ++-- apps/desktop/packaging/README.md | 2 +- apps/desktop/packaging/README.zh.md | 2 +- .../scripts/desktop-package-environment.d.mts | 2 +- .../scripts/desktop-package-environment.mjs | 2 +- apps/desktop/scripts/windows-token-identity.ts | 2 +- .../tests/desktop-package-environment.spec.ts | 2 +- apps/desktop/tests/windows-token-identity.spec.ts | 14 +++++++++++++- apps/desktop/windows-signing/README.i18n.yaml | 4 ++-- apps/desktop/windows-signing/README.md | 2 +- apps/desktop/windows-signing/README.zh.md | 2 +- scripts/windows-build-workspace.spec.ts | 4 ++-- 12 files changed, 27 insertions(+), 15 deletions(-) diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index 9b36383e84..3c6190e6bf 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -45,8 +45,8 @@ en: b42f103358d0dfd7 zh: a534164ad9eb00da /desktop-packaging/runtime-file-selection-and-installation-checks: - en: 10c919aa787c1884 - zh: e46c4838793224dd + en: abf7aa7bc2d37ac3 + zh: d7011883eec14d89 /desktop-packaging/desktop-runtime-preparation: en: 59783a64552d4464 zh: d7876d51b4015578 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index 5dd4f314e3..911bca3bfe 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -265,7 +265,7 @@ When extraction fails, the installer writes a report with the 7-Zip result and i Windows packaging fixes the 7-Zip filter to `BCJ` for compatibility with the bundled NSIS decoder. This preserves ARM64 binaries carried by dependencies in x64 installers; automatic ARM64 filtering produces entries that this decoder cannot extract. -`DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL` in the Windows dotenv or machine preset selects installer archive compression from `0` to `9`. If omitted, the existing `ELECTRON_BUILDER_COMPRESSION_LEVEL` environment override remains effective, otherwise the default is `3` for both test and production. The manual Windows workflow's `compression_level` dropdown defaults to `3` and also offers `7`, matching the Mac ZIP choices; it overrides the file setting for that run. Direct local builds retain explicit machine settings. Nonempty workflow compression overrides reject the other target platform. Lower levels trade installer size for packaging time; qualify the complete signed workflow and artifact size before changing a release preset. +`DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL` in the Windows dotenv or machine preset selects installer archive compression from `0` to `9`. If omitted, the existing `ELECTRON_BUILDER_COMPRESSION_LEVEL` environment override remains effective, otherwise the default is `3` for both test and production. The manual Windows workflow's `compression_level` dropdown defaults to `3` and also offers `7`, matching the Mac ZIP choices; it overrides the file setting for that run. Direct local builds retain explicit machine settings. Any defined Mac ZIP workflow override, including an empty value, rejects Windows; nonempty Windows workflow overrides reject macOS. Lower levels trade installer size for packaging time; qualify the complete signed workflow and artifact size before changing a release preset. NSIS removes its temporary extraction tree during installation, before the completion page or an automatic launch. The installed production packages remain ordinary files; startup does not extract them again. Installation still writes the complete application tree. diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index fa604b2370..414533aceb 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -265,7 +265,7 @@ Windows 安装器在启动时和选定目标目录后检查应用是否正在运 Windows 打包将 7-Zip 过滤器固定为 `BCJ`,以兼容内置的 NSIS 解码器。这样可以保留 x64 安装包中由依赖携带的 ARM64 二进制文件;自动 ARM64 过滤会生成该解码器无法解压的条目。 -Windows dotenv 或机器 preset 中的 `DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL` 选择 `0` 至 `9` 的安装包归档压缩等级。省略时,现有 `ELECTRON_BUILDER_COMPRESSION_LEVEL` 环境覆盖仍然生效,否则 test 和 production 均默认为 `3`。手动 Windows workflow 的 `compression_level` 下拉默认选 `3`,也提供 `7`,与 Mac ZIP 选项一致;它对本轮覆盖文件中的设置。直接本地打包保留显式机器配置。非空的 workflow 压缩覆盖值会拒绝用于另一目标平台。较低等级以安装包体积换取打包时间;修改发布 preset 前应验证完整签名 workflow 和产物体积。 +Windows dotenv 或机器 preset 中的 `DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL` 选择 `0` 至 `9` 的安装包归档压缩等级。省略时,现有 `ELECTRON_BUILDER_COMPRESSION_LEVEL` 环境覆盖仍然生效,否则 test 和 production 均默认为 `3`。手动 Windows workflow 的 `compression_level` 下拉默认选 `3`,也提供 `7`,与 Mac ZIP 选项一致;它对本轮覆盖文件中的设置。直接本地打包保留显式机器配置。任何已定义的 Mac ZIP workflow 覆盖值(包括空值)均拒绝用于 Windows;非空的 Windows workflow 覆盖值拒绝用于 macOS。较低等级以安装包体积换取打包时间;修改发布 preset 前应验证完整签名 workflow 和产物体积。 NSIS 在安装阶段清理临时解压目录,完成后才显示完成页或自动启动应用。已安装的生产依赖保持为普通文件;启动时不会再次解压。安装仍会写入完整的应用目录树。 diff --git a/apps/desktop/scripts/desktop-package-environment.d.mts b/apps/desktop/scripts/desktop-package-environment.d.mts index 2b04398d11..6ad042b4e8 100644 --- a/apps/desktop/scripts/desktop-package-environment.d.mts +++ b/apps/desktop/scripts/desktop-package-environment.d.mts @@ -11,7 +11,7 @@ export function localMacOSPackageEnvironment(environment?: NodeJS.ProcessEnv): N * Read a machine preset when selected, otherwise the target's required dotenv; no ambient release fallback. * PACKAGE_TEST_RELEASE_ID overrides the test preset only; blank retains its configured ID. * PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL selects 3 or 7 for Mac workflow builds, overriding the file setting. - * Nonempty workflow compression overrides reject the other platform. + * Any defined Mac ZIP override rejects Windows, including an empty value; nonempty Windows overrides reject macOS. * PACKAGE_WINDOWS_COMPRESSION_LEVEL selects 3 or 7 for Windows workflow builds; blank retains the file setting. * @param platform Target platform. * @param environment Parent environment, retained only for unrelated build tools. diff --git a/apps/desktop/scripts/desktop-package-environment.mjs b/apps/desktop/scripts/desktop-package-environment.mjs index e6e4f2f071..be62e1924e 100644 --- a/apps/desktop/scripts/desktop-package-environment.mjs +++ b/apps/desktop/scripts/desktop-package-environment.mjs @@ -37,7 +37,7 @@ export function localMacOSPackageEnvironment(environment = process.env) { /** * Read a machine preset when selected, otherwise the target's required dotenv; no ambient release fallback. * PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL selects 3 or 7 for Mac workflow builds, overriding the file setting. - * Nonempty workflow compression overrides reject the other platform. + * Any defined Mac ZIP override rejects Windows, including an empty value; nonempty Windows overrides reject macOS. * PACKAGE_TEST_RELEASE_ID overrides the test preset only; blank retains its configured ID. * PACKAGE_WINDOWS_COMPRESSION_LEVEL selects 3 or 7 for Windows workflow builds; blank retains the file setting. * @param {'win32' | 'darwin'} platform Target platform. diff --git a/apps/desktop/scripts/windows-token-identity.ts b/apps/desktop/scripts/windows-token-identity.ts index 3171163c20..43d532bcc4 100644 --- a/apps/desktop/scripts/windows-token-identity.ts +++ b/apps/desktop/scripts/windows-token-identity.ts @@ -81,7 +81,7 @@ export function windowsTokenIdentityFailure(result: Exclude { +it.each(['', '3', '7'])('rejects Mac workflow ZIP choice %j on Windows before reading settings', (level) => { expect(() => loadDesktopPackageEnvironment('win32', { PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL: level }, 'missing-settings')) .toThrow('PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL requires macOS') }) diff --git a/apps/desktop/tests/windows-token-identity.spec.ts b/apps/desktop/tests/windows-token-identity.spec.ts index d184c23f6b..e56cd689b5 100644 --- a/apps/desktop/tests/windows-token-identity.spec.ts +++ b/apps/desktop/tests/windows-token-identity.spec.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { rootCertificates } from 'node:tls' import { expect, it, vi } from 'vitest' -import { inspectWindowsTokenIdentity } from '../scripts/windows-token-identity.ts' +import { inspectWindowsTokenIdentity, windowsTokenIdentityFailure } from '../scripts/windows-token-identity.ts' const launch = vi.hoisted(() => vi.fn(async (_file: string, _args: string[], _options: { env: NodeJS.ProcessEnv; timeout: number }) => ({ stdout: '', stderr: '' }))) vi.mock('node:child_process', async (importOriginal) => { @@ -78,6 +78,18 @@ it('reports a helper timeout or stderr as unknown without retry', async () => { expect(await inspectWindowsTokenIdentity(input)).toBe('unknown') }) +it('reports a missing query launcher without exposing process diagnostics or retrying', async () => { + launch.mockClear() + launch.mockRejectedValueOnce(Object.assign(new Error('private launcher diagnostic'), { code: 'ENOENT' })) + const result = await inspectWindowsTokenIdentity(input) + expect(result).toBe('unknown') + expect(launch).toHaveBeenCalledOnce() + const message = windowsTokenIdentityFailure('unknown') + expect(message).toContain('PowerShell 7 (pwsh) can start') + expect(message).not.toContain('private launcher diagnostic') + expect(message).toContain('no signing was attempted') +}) + it.skipIf(process.platform !== 'win32')('exercises native control flow with a fake CSP and never constructs the native adapter', () => { const root = mkdtempSync(join(tmpdir(), 'token-public-test-')) try { diff --git a/apps/desktop/windows-signing/README.i18n.yaml b/apps/desktop/windows-signing/README.i18n.yaml index edb31b9766..3225b5184f 100644 --- a/apps/desktop/windows-signing/README.i18n.yaml +++ b/apps/desktop/windows-signing/README.i18n.yaml @@ -27,8 +27,8 @@ en: 4b5548818375eb9a zh: 8c975adbb1dca9c8 /windows-signing-and-runner-operations/signing-and-timestamp-completion: - en: 74003162e4c21446 - zh: a8392e981eaec99c + en: a8d23c931cafc236 + zh: 28fd4860549cdc7b /windows-signing-and-runner-operations/failure-records-and-operator-recovery: en: 5401b605a2f3a44e zh: fc1f6ee667bcd20d diff --git a/apps/desktop/windows-signing/README.md b/apps/desktop/windows-signing/README.md index 579ccc8ae1..dddbad655b 100644 --- a/apps/desktop/windows-signing/README.md +++ b/apps/desktop/windows-signing/README.md @@ -66,7 +66,7 @@ Follow the [version and build instructions](../packaging/README.md#release-versi Signed Windows builds run a supervised signing preflight before compilation or dependency preparation. Static configuration, certificate validity, audit storage, compiler availability and any retained signing interlock are checked without token access. The local .NET Framework C# compiler creates a small private probe; the production signer signs it once, and verification requires the configured certificate and a timestamp before building continues. The probe is never executed. The overall 60-second preflight deadline includes timestamp attempts; deadline expiry, hardware signing errors and verification failures stop the run without another hardware attempt. Success proves the current signing path works, not that the PIN was independently authenticated: SafeNet may reuse login state. Do not log out or repeat authentication to test the PIN. `--check`, preparation-only and `--unsigned` modes do not run this hardware preflight; unsigned artifacts remain ineligible for release upload. Automated regression tests use a fake signer; release operators qualify real hardware separately. -Before compiling the probe, a separate noninteractive process reads public keys from the fixed eToken CSP using `CRYPT_VERIFYCONTEXT | CRYPT_SILENT`. It receives the container name but no PIN or signing credentials, and has a 15-second deadline within the existing preflight deadline. Both RSA key slots must be read completely (an absent slot is allowed); at least one public key must match the configured certificate. Provider/container access failure, unsupported public reads, malformed output, timeout or mismatch stops before hardware signing. A container-access error does not establish that the device or container is absent. Public information can be cached by middleware: a match does not authenticate the device, validate the PIN or establish remaining retries. This path is covered by a simulated CSP; the installed SafeNet version still requires separate operator qualification. No failure automatically retries authentication or clears the existing interlock. +Public identity queries require PowerShell 7 (`pwsh`) on the build account’s `PATH`; an unavailable or failed query process stops signing with an unknown identity result. Before compiling the probe, a separate noninteractive process reads public keys from the fixed eToken CSP using `CRYPT_VERIFYCONTEXT | CRYPT_SILENT`. It receives the container name but no PIN or signing credentials, and has a 15-second deadline within the existing preflight deadline. Both RSA key slots must be read completely (an absent slot is allowed); at least one public key must match the configured certificate. Provider/container access failure, unsupported public reads, malformed output, timeout or mismatch stops before hardware signing. A container-access error does not establish that the device or container is absent. Public information can be cached by middleware: a match does not authenticate the device, validate the PIN or establish remaining retries. This path is covered by a simulated CSP; the installed SafeNet version still requires separate operator qualification. No failure automatically retries authentication or clears the existing interlock. Insert and unlock the token before packaging. The electron-builder hook passes each artifact to the CRLF `scripts/windows-sign.cmd`, which invokes the configured SignTool once with `/f`, SafeNet `/kc "[{{PIN}}]=container"`, `/csp "eToken Base Cryptographic Provider"`, a SHA-256 file digest, without a timestamp. The hook then completes a DigiCert SHA-256 RFC 3161 timestamp on isolated copies without signing credentials. The hook never substitutes electron-builder's bundled SignTool and never retries a failed signing request. Windows release packaging fails instead of emitting unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. diff --git a/apps/desktop/windows-signing/README.zh.md b/apps/desktop/windows-signing/README.zh.md index 65996941cd..de21ae03fa 100644 --- a/apps/desktop/windows-signing/README.zh.md +++ b/apps/desktop/windows-signing/README.zh.md @@ -66,7 +66,7 @@ PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet Windows 签名构建在编译或准备依赖前执行受监督的签名预检。静态配置、证书有效期、审计存储、编译器可用性及遗留签名锁的检查不访问 Token。本地 .NET Framework C# 编译器生成一个专用小探针,由正式签名器仅签名一次,随后必须验出配置的证书和时间戳才能继续构建。探针绝不执行。预检的整体 60 秒期限包含时间戳尝试;超时、硬件签名报错或验签失败都会停止本轮流程,不再次调用硬件。成功只证明当前签名路径可用,不证明 PIN 已独立认证:SafeNet 可能复用登录状态。不要为了验证 PIN 而注销或重复认证。`--check`、仅准备和 `--unsigned` 模式不执行此硬件预检;未签名产物仍不能发布上传。自动回归测试使用假签名器,真实硬件由发布操作人员单独验收。 -编译探针前,独立非交互进程使用 `CRYPT_VERIFYCONTEXT | CRYPT_SILENT` 从固定的 eToken CSP 读取公钥。该进程只接收容器名,不接收 PIN 或签名凭据;15 秒期限计入已有预检总期限。两个 RSA 密钥槽必须完整读取(允许槽不存在),且至少一个公钥与配置证书匹配。提供程序或容器访问失败、不支持公开读取、输出损坏、超时或不匹配时,在硬件签名前停止。容器访问错误不能证明设备或容器不存在。中间件可能缓存公开信息,因此匹配不代表设备已认证、PIN 正确或已确认剩余尝试次数。此路径已由模拟 CSP 覆盖,当前 SafeNet 版本仍需操作人员单独验收。任何失败都不会自动重试认证或清除已有保护锁。 +公开身份查询要求构建账户的 `PATH` 中有 PowerShell 7(`pwsh`);查询进程不可用或失败时,以身份未知结果停止签名。编译探针前,独立非交互进程使用 `CRYPT_VERIFYCONTEXT | CRYPT_SILENT` 从固定的 eToken CSP 读取公钥。该进程只接收容器名,不接收 PIN 或签名凭据;15 秒期限计入已有预检总期限。两个 RSA 密钥槽必须完整读取(允许槽不存在),且至少一个公钥与配置证书匹配。提供程序或容器访问失败、不支持公开读取、输出损坏、超时或不匹配时,在硬件签名前停止。容器访问错误不能证明设备或容器不存在。中间件可能缓存公开信息,因此匹配不代表设备已认证、PIN 正确或已确认剩余尝试次数。此路径已由模拟 CSP 覆盖,当前 SafeNet 版本仍需操作人员单独验收。任何失败都不会自动重试认证或清除已有保护锁。 打包前插入并解锁 Token。electron-builder 钩子把每个产物交给采用 CRLF 的 `scripts/windows-sign.cmd`;该 CMD 只调用一次已配置的 SignTool,并指定 `/f`、SafeNet `/kc "[{{PIN}}]=容器"`、`/csp "eToken Base Cryptographic Provider"`和 SHA-256 文件摘要,不请求时间戳。随后钩子在隔离副本上完成 DigiCert SHA-256 RFC 3161 时间戳,不传递签名凭据。钩子不会改用 electron-builder 内置的 SignTool,也不会重试失败的签名请求。SignTool、证书、容器、PIN、Token 或签名不可用时,Windows 发布打包会失败,不会生成未签名产物。 diff --git a/scripts/windows-build-workspace.spec.ts b/scripts/windows-build-workspace.spec.ts index aac6bf182f..a47004089b 100644 --- a/scripts/windows-build-workspace.spec.ts +++ b/scripts/windows-build-workspace.spec.ts @@ -1,12 +1,12 @@ import { execFileSync } from 'node:child_process' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { expect, it } from 'vitest' import { prepareWindowsBuildWorkspace, removeWindowsBuildWorkspace } from '../apps/desktop/scripts/windows-build-workspace.mjs' it('clones the exact clean commit and removes only its run-owned checkout', () => { - const root = mkdtempSync(join(tmpdir(), 'package-workspace-')) + const root = realpathSync(mkdtempSync(join(tmpdir(), 'package-workspace-'))) try { const sourceDirectory = join(root, 'original') mkdirSync(sourceDirectory) From 5a407d497fc92abf2a337bcd5fc78dff88231f38 Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:08:53 +0800 Subject: [PATCH 30/46] fix(build): order bundlers before shared client output writes --- apps/desktop/packaging/README.i18n.yaml | 8 ++++---- apps/desktop/packaging/README.md | 4 ++-- apps/desktop/packaging/README.zh.md | 4 ++-- apps/desktop/scripts/desktop-package-environment.d.mts | 2 +- apps/desktop/scripts/desktop-package-environment.mjs | 2 +- apps/desktop/scripts/windows-token-identity.ts | 2 +- docs/development.i18n.yaml | 4 ++-- docs/development.md | 2 +- docs/development.zh.md | 2 +- scripts/build.spec.ts | 6 +++--- scripts/compile-referenced-projects.ts | 8 ++++---- 11 files changed, 22 insertions(+), 22 deletions(-) diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index 3c6190e6bf..e99477593a 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -30,8 +30,8 @@ en: bb1dcdfef3f3a113 zh: 9c151800a61e2505 /desktop-packaging/unsigned-windows-test-installer: - en: 6843a0fdc03ead58 - zh: 957f92fc8126e23e + en: f429bc0c280e2dfa + zh: 4adbcc9e906bdf80 /desktop-packaging/local-macos-dmg-without-release-signing: en: 2feaa0412247d81f zh: 461dde8e13fd4734 @@ -45,8 +45,8 @@ en: b42f103358d0dfd7 zh: a534164ad9eb00da /desktop-packaging/runtime-file-selection-and-installation-checks: - en: abf7aa7bc2d37ac3 - zh: d7011883eec14d89 + en: 83e1075417039410 + zh: 3088a7ae0a2cc3c5 /desktop-packaging/desktop-runtime-preparation: en: 59783a64552d4464 zh: d7876d51b4015578 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index 911bca3bfe..e226c61d11 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -156,7 +156,7 @@ On Windows x64, use the complete unsigned packaging command for local installati pnpm run package:desktop:win:x64:unsigned ``` -The command reads `.env.windows` and requires `DSH_DESKTOP_APP_ID` and the [mandatory-update policy settings](../README.md#mandatory-update-policy), including a real HTTPS service origin. The local run and CI use Node 24; the remaining tools are the root `packageManager` version of pnpm, Git, PowerShell, tar, Python, Visual C++ Build Tools and a Windows SDK. Set `PYTHON` when Python is absent from `PATH`. It writes the installer to `.desktop-build/targets/win-x64/unsigned-artifacts/`, omits ordinary automatic-update configuration, strips signing credentials, and creates no release completion record. EV and upload credentials are unnecessary. Signed packaging and upload retain their release requirements. +The command reads `.env.windows` and requires `DSH_DESKTOP_APP_ID` and the [mandatory-update policy settings](../README.md#mandatory-update-policy), including a real HTTPS service origin. The local run and CI use Node 24; the remaining tools are the root `packageManager` version of pnpm, Git, PowerShell 7 (`pwsh`), tar, Python, Visual C++ Build Tools and a Windows SDK. Set `PYTHON` when Python is absent from `PATH`. It writes the installer to `.desktop-build/targets/win-x64/unsigned-artifacts/`, omits ordinary automatic-update configuration, strips signing credentials, and creates no release completion record. EV and upload credentials are unnecessary. Signed packaging and upload retain their release requirements. Run `pnpm install --frozen-lockfile`, then `pnpm run package:desktop:win:x64:unsigned --check` to validate configuration and tooling before the full command above. Packaging performs its own complete build and isolated runtime smoke without an online model. Stage logs remain under `.desktop-build/packaging-runs/`; a successful build alone does not qualify the installer. Dependency installation and runtime preparation need access to npm, GitHub release assets and nodejs.org; use the existing registry and proxy configuration when the network requires it. Caches and tool locations belong to the build machine, not tracked personal paths. @@ -265,7 +265,7 @@ When extraction fails, the installer writes a report with the 7-Zip result and i Windows packaging fixes the 7-Zip filter to `BCJ` for compatibility with the bundled NSIS decoder. This preserves ARM64 binaries carried by dependencies in x64 installers; automatic ARM64 filtering produces entries that this decoder cannot extract. -`DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL` in the Windows dotenv or machine preset selects installer archive compression from `0` to `9`. If omitted, the existing `ELECTRON_BUILDER_COMPRESSION_LEVEL` environment override remains effective, otherwise the default is `3` for both test and production. The manual Windows workflow's `compression_level` dropdown defaults to `3` and also offers `7`, matching the Mac ZIP choices; it overrides the file setting for that run. Direct local builds retain explicit machine settings. Any defined Mac ZIP workflow override, including an empty value, rejects Windows; nonempty Windows workflow overrides reject macOS. Lower levels trade installer size for packaging time; qualify the complete signed workflow and artifact size before changing a release preset. +`DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL` in the Windows dotenv or machine preset selects installer archive compression from `0` to `9`. If omitted, the existing `ELECTRON_BUILDER_COMPRESSION_LEVEL` environment override remains effective, otherwise the default is `3` for both test and production. The manual Windows workflow's `compression_level` dropdown defaults to `3` and also offers `7`, matching the Mac ZIP choices; it overrides the file setting for that run. Direct local builds retain explicit machine settings. Mac ZIP workflow overrides must be `3` or `7`; an empty value is invalid even on macOS. Any defined Mac ZIP workflow override, including an empty value, rejects Windows; nonempty Windows workflow overrides reject macOS. Lower levels trade installer size for packaging time; qualify the complete signed workflow and artifact size before changing a release preset. NSIS removes its temporary extraction tree during installation, before the completion page or an automatic launch. The installed production packages remain ordinary files; startup does not extract them again. Installation still writes the complete application tree. diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index 414533aceb..cf8edbc322 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -156,7 +156,7 @@ bash apps/desktop/scripts/macos-runner-service.sh status "$HOME/dsh-ci/runner" pnpm run package:desktop:win:x64:unsigned ``` -该命令读取 `.env.windows`,要求设置 `DSH_DESKTOP_APP_ID` 和[强制更新策略配置](../README.zh.md#mandatory-update-policy),包括真实的 HTTPS 服务 origin。本机验收和 CI 使用 Node 24;其他工具为根 `packageManager` 固定版本的 pnpm、Git、PowerShell、tar、Python、Visual C++ Build Tools 和 Windows SDK。Python 不在 `PATH` 中时设置 `PYTHON`。命令将安装包写入 `.desktop-build/targets/win-x64/unsigned-artifacts/`,省略普通自动更新配置,清除签名凭据,且不生成发布完成记录。无需 EV 或上传凭据。签名打包和上传仍遵循正式发布要求。 +该命令读取 `.env.windows`,要求设置 `DSH_DESKTOP_APP_ID` 和[强制更新策略配置](../README.zh.md#mandatory-update-policy),包括真实的 HTTPS 服务 origin。本机验收和 CI 使用 Node 24;其他工具为根 `packageManager` 固定版本的 pnpm、Git、PowerShell 7(`pwsh`)、tar、Python、Visual C++ Build Tools 和 Windows SDK。Python 不在 `PATH` 中时设置 `PYTHON`。命令将安装包写入 `.desktop-build/targets/win-x64/unsigned-artifacts/`,省略普通自动更新配置,清除签名凭据,且不生成发布完成记录。无需 EV 或上传凭据。签名打包和上传仍遵循正式发布要求。 先运行 `pnpm install --frozen-lockfile`,再运行 `pnpm run package:desktop:win:x64:unsigned --check` 校验配置和工具,最后执行上面的完整命令。打包自行执行完整构建和隔离的运行时 smoke,不调用在线模型。阶段日志保留在 `.desktop-build/packaging-runs/`;仅构建成功不代表安装包验证通过。依赖安装和运行时准备需要访问 npm、GitHub release 资源和 nodejs.org;网络需要时使用现有 registry 和代理配置。缓存与工具位置属于构建机器配置,不在跟踪文件中固定个人路径。 @@ -265,7 +265,7 @@ Windows 安装器在启动时和选定目标目录后检查应用是否正在运 Windows 打包将 7-Zip 过滤器固定为 `BCJ`,以兼容内置的 NSIS 解码器。这样可以保留 x64 安装包中由依赖携带的 ARM64 二进制文件;自动 ARM64 过滤会生成该解码器无法解压的条目。 -Windows dotenv 或机器 preset 中的 `DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL` 选择 `0` 至 `9` 的安装包归档压缩等级。省略时,现有 `ELECTRON_BUILDER_COMPRESSION_LEVEL` 环境覆盖仍然生效,否则 test 和 production 均默认为 `3`。手动 Windows workflow 的 `compression_level` 下拉默认选 `3`,也提供 `7`,与 Mac ZIP 选项一致;它对本轮覆盖文件中的设置。直接本地打包保留显式机器配置。任何已定义的 Mac ZIP workflow 覆盖值(包括空值)均拒绝用于 Windows;非空的 Windows workflow 覆盖值拒绝用于 macOS。较低等级以安装包体积换取打包时间;修改发布 preset 前应验证完整签名 workflow 和产物体积。 +Windows dotenv 或机器 preset 中的 `DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL` 选择 `0` 至 `9` 的安装包归档压缩等级。省略时,现有 `ELECTRON_BUILDER_COMPRESSION_LEVEL` 环境覆盖仍然生效,否则 test 和 production 均默认为 `3`。手动 Windows workflow 的 `compression_level` 下拉默认选 `3`,也提供 `7`,与 Mac ZIP 选项一致;它对本轮覆盖文件中的设置。直接本地打包保留显式机器配置。Mac ZIP workflow 覆盖值必须为 `3` 或 `7`;空值在 macOS 上也非法。任何已定义的 Mac ZIP workflow 覆盖值(包括空值)均拒绝用于 Windows;非空的 Windows workflow 覆盖值拒绝用于 macOS。较低等级以安装包体积换取打包时间;修改发布 preset 前应验证完整签名 workflow 和产物体积。 NSIS 在安装阶段清理临时解压目录,完成后才显示完成页或自动启动应用。已安装的生产依赖保持为普通文件;启动时不会再次解压。安装仍会写入完整的应用目录树。 diff --git a/apps/desktop/scripts/desktop-package-environment.d.mts b/apps/desktop/scripts/desktop-package-environment.d.mts index 6ad042b4e8..ec9da721f1 100644 --- a/apps/desktop/scripts/desktop-package-environment.d.mts +++ b/apps/desktop/scripts/desktop-package-environment.d.mts @@ -10,7 +10,7 @@ export function localMacOSPackageEnvironment(environment?: NodeJS.ProcessEnv): N /** * Read a machine preset when selected, otherwise the target's required dotenv; no ambient release fallback. * PACKAGE_TEST_RELEASE_ID overrides the test preset only; blank retains its configured ID. - * PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL selects 3 or 7 for Mac workflow builds, overriding the file setting. + * PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL overrides the Mac file setting with 3 or 7; an empty value is invalid. * Any defined Mac ZIP override rejects Windows, including an empty value; nonempty Windows overrides reject macOS. * PACKAGE_WINDOWS_COMPRESSION_LEVEL selects 3 or 7 for Windows workflow builds; blank retains the file setting. * @param platform Target platform. diff --git a/apps/desktop/scripts/desktop-package-environment.mjs b/apps/desktop/scripts/desktop-package-environment.mjs index be62e1924e..9877b25d09 100644 --- a/apps/desktop/scripts/desktop-package-environment.mjs +++ b/apps/desktop/scripts/desktop-package-environment.mjs @@ -36,7 +36,7 @@ export function localMacOSPackageEnvironment(environment = process.env) { /** * Read a machine preset when selected, otherwise the target's required dotenv; no ambient release fallback. - * PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL selects 3 or 7 for Mac workflow builds, overriding the file setting. + * PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL overrides the Mac file setting with 3 or 7; an empty value is invalid. * Any defined Mac ZIP override rejects Windows, including an empty value; nonempty Windows overrides reject macOS. * PACKAGE_TEST_RELEASE_ID overrides the test preset only; blank retains its configured ID. * PACKAGE_WINDOWS_COMPRESSION_LEVEL selects 3 or 7 for Windows workflow builds; blank retains the file setting. diff --git a/apps/desktop/scripts/windows-token-identity.ts b/apps/desktop/scripts/windows-token-identity.ts index 43d532bcc4..a36ddf1d72 100644 --- a/apps/desktop/scripts/windows-token-identity.ts +++ b/apps/desktop/scripts/windows-token-identity.ts @@ -81,7 +81,7 @@ export function windowsTokenIdentityFailure(result: Exclude { }) describe('repository build steps', () => { - it('overlaps Client compilation with Host bundling while ordering bundle writers', () => { + it('finishes Host and Desktop readers before Client compilation rewrites shared outputs', () => { expect(graph(buildGates(root, 'artifacts'))).toEqual(artifactGraph) }) diff --git a/scripts/compile-referenced-projects.ts b/scripts/compile-referenced-projects.ts index 03b9a982be..ce38c35649 100644 --- a/scripts/compile-referenced-projects.ts +++ b/scripts/compile-referenced-projects.ts @@ -166,8 +166,8 @@ function pnpmStep(id: LibraryStepId, label: string, args: readonly string[], nee /** * List the package-library build steps in Host, Desktop, then Client dependency order. * Source-only Typert generation supplies declarations before any package compilation. - * Client compilation consumes Host declarations and overlaps Host and Desktop bundling; - * Client bundling waits for both branches so bundle writers remain ordered. + * Client compilation waits for Host and Desktop bundling because split compiler faces + * rewrite shared lib/types files consumed by those bundles. * @param root - Repository root containing the compiler and bundle configurations. * @param faces - `host` lists Typert generation, Host compilation, and bundling; `all` adds Desktop and Client. * @param mode - Check package types by default; benchmark emission defers diagnostics to required full builds. @@ -184,8 +184,8 @@ export function libraryBuildSteps(root: string, faces: 'host' | 'all', mode: Com return [ ...host, pnpmStep('desktop-bundle', 'Desktop bundle', ['--filter', '@deepseek-ai/dsh-desktop', 'run', 'bundle'], ['host-bundle']), - compilerStep(root, 'client', mode, ['host-src']), - pnpmStep('client-bundle', 'Client bundle', ['exec', 'tsdown', '--config-loader', 'native', '--env.DSH_BUILD_FACE', 'client'], ['desktop-bundle', 'client-src']), + compilerStep(root, 'client', mode, ['desktop-bundle']), + pnpmStep('client-bundle', 'Client bundle', ['exec', 'tsdown', '--config-loader', 'native', '--env.DSH_BUILD_FACE', 'client'], ['client-src']), ] } From 02460a0eb4a1603dbba88c4fd6b7a67ddd7d923f Mon Sep 17 00:00:00 2001 From: winewill Date: Fri, 23 Oct 2026 15:06:25 +0800 Subject: [PATCH 31/46] fix(desktop): align release app ID examples with production --- apps/desktop/.env.macos.example | 2 +- apps/desktop/.env.windows.example | 2 +- apps/desktop/config/macos-packaging/production.env.example | 2 +- apps/desktop/config/macos-packaging/test.env.example | 2 +- apps/desktop/config/windows-packaging/production.env.example | 2 +- apps/desktop/config/windows-packaging/test.env.example | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/desktop/.env.macos.example b/apps/desktop/.env.macos.example index da5502ce5d..d842675ede 100644 --- a/apps/desktop/.env.macos.example +++ b/apps/desktop/.env.macos.example @@ -1,7 +1,7 @@ # Release configuration only; local --unsigned builds ignore this file. # Copy to .env.macos (Git ignored). Use UTF-8 and quote secrets containing # or spaces. # Paths may be absolute or relative to apps/desktop. Values are not shell-expanded. -DSH_DESKTOP_APP_ID=com.deepseek.harness +DSH_DESKTOP_APP_ID=com.deepseek.dsh DSH_DESKTOP_AUTO_UPDATE_ENV=test DOWNLOAD_TEST_ORIGIN= # Required for test releases: 32 lowercase hexadecimal characters from a secure random generator. diff --git a/apps/desktop/.env.windows.example b/apps/desktop/.env.windows.example index 4950c70b27..5d74875d45 100644 --- a/apps/desktop/.env.windows.example +++ b/apps/desktop/.env.windows.example @@ -1,6 +1,6 @@ # Copy to .env.windows (Git ignored). Use UTF-8 and quote secrets containing # or spaces. # Credential file paths may be absolute or relative to apps/desktop. Values are not shell-expanded. -DSH_DESKTOP_APP_ID=com.deepseek.harness +DSH_DESKTOP_APP_ID=com.deepseek.dsh DSH_DESKTOP_AUTO_UPDATE_ENV=test DOWNLOAD_TEST_ORIGIN= # Required for test releases: 32 lowercase hexadecimal characters from a secure random generator. diff --git a/apps/desktop/config/macos-packaging/production.env.example b/apps/desktop/config/macos-packaging/production.env.example index 18360eadef..d73c3bb69c 100644 --- a/apps/desktop/config/macos-packaging/production.env.example +++ b/apps/desktop/config/macos-packaging/production.env.example @@ -1,5 +1,5 @@ # Copy to production.env and configure the production service before selecting this preset. -DSH_DESKTOP_APP_ID=com.deepseek.harness +DSH_DESKTOP_APP_ID=com.deepseek.dsh DSH_DESKTOP_AUTO_UPDATE_ENV=production DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN= DSH_DESKTOP_MACOS_PACK_CONCURRENCY=4 diff --git a/apps/desktop/config/macos-packaging/test.env.example b/apps/desktop/config/macos-packaging/test.env.example index aa40a119ce..eb0cb7d743 100644 --- a/apps/desktop/config/macos-packaging/test.env.example +++ b/apps/desktop/config/macos-packaging/test.env.example @@ -1,5 +1,5 @@ # Copy to test.env; paths in secret configuration resolve beside macos-signing.env. -DSH_DESKTOP_APP_ID=com.deepseek.harness +DSH_DESKTOP_APP_ID=com.deepseek.dsh DSH_DESKTOP_AUTO_UPDATE_ENV=test DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN= DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{"allowedAuthOrigins":["https://login.example.com"]}' diff --git a/apps/desktop/config/windows-packaging/production.env.example b/apps/desktop/config/windows-packaging/production.env.example index 2e05825590..b7493890ff 100644 --- a/apps/desktop/config/windows-packaging/production.env.example +++ b/apps/desktop/config/windows-packaging/production.env.example @@ -1,5 +1,5 @@ # Copy to production.env in the same machine configuration directory and fill in the values. -DSH_DESKTOP_APP_ID=com.deepseek.harness +DSH_DESKTOP_APP_ID=com.deepseek.dsh DSH_DESKTOP_AUTO_UPDATE_ENV=production DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN= DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{}' diff --git a/apps/desktop/config/windows-packaging/test.env.example b/apps/desktop/config/windows-packaging/test.env.example index 718481f13a..a78ae65e35 100644 --- a/apps/desktop/config/windows-packaging/test.env.example +++ b/apps/desktop/config/windows-packaging/test.env.example @@ -1,6 +1,6 @@ # Copy to test.env in the same machine configuration directory and fill in the values. # Public test configuration. No PIN or upload credentials belong here. -DSH_DESKTOP_APP_ID=com.deepseek.harness +DSH_DESKTOP_APP_ID=com.deepseek.dsh DSH_DESKTOP_AUTO_UPDATE_ENV=test DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN= # Replace login.example.com with the real login origin before packaging. From 7623307aafe49d18510cacd3e0e1269ea8d6bd76 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 23 Oct 2026 15:31:51 +0800 Subject: [PATCH 32/46] fix(dockkit): continue the tab strip from its last offset on selection changes Each tab host draws its own strip while selected, so every open, close, or selection mounted a strip at offset 0 that then glided across the whole row to the active chip. Record each pane's chip-box offset in TabLayout, jump a new strip to it, and glide only the remaining distance; a first placement with no recorded offset jumps. Refs #7049 --- packages/client/ui-dockkit/README.i18n.yaml | 4 +- packages/client/ui-dockkit/README.md | 2 +- packages/client/ui-dockkit/README.zh.md | 2 +- .../ui-dockkit/src/components/TabLayout.tsx | 12 ++- .../ui-dockkit/src/components/TabPanel.tsx | 61 +++++++++++-- .../tests/tab-layout.client.spec.tsx | 88 +++++++++++++++++++ 6 files changed, 154 insertions(+), 15 deletions(-) diff --git a/packages/client/ui-dockkit/README.i18n.yaml b/packages/client/ui-dockkit/README.i18n.yaml index f631fbecc2..4863e9497a 100644 --- a/packages/client/ui-dockkit/README.i18n.yaml +++ b/packages/client/ui-dockkit/README.i18n.yaml @@ -21,8 +21,8 @@ en: c4ba08038bfa6f30 zh: 95db30618b52babe /deepseek-ai-dsh-client-ui-dockkit/interaction-rules-worth-keeping: - en: 7e5c1e9a9f9f244f - zh: 7e3a8fcef86bc7dd + en: 6ebe16d5610bb8f8 + zh: 8442e617bc1f52b2 /deepseek-ai-dsh-client-ui-dockkit/build-shape: en: 21216ba5798d7f5f zh: ac505fdcd5c114d4 diff --git a/packages/client/ui-dockkit/README.md b/packages/client/ui-dockkit/README.md index 976148bde2..813588feed 100644 --- a/packages/client/ui-dockkit/README.md +++ b/packages/client/ui-dockkit/README.md @@ -74,7 +74,7 @@ The split control accepts localized tooltip text, separate effective keys throug These are not stylistic; each one fixes a defect found in a real browser. - **Capture the pointer** when a gesture starts. Without it any scroll container the pointer crosses can claim the gesture, which the browser reports as a cancelled pointer and an abandoned drag. Capture is hardening — the window listeners carry the gesture either way, so an environment without the API still works. -- **The chips give way; the strip's end controls never do.** The chip box is the strip's one shrinking part (`flex: 0 1 auto; min-width: 0; overflow-x: auto`): chips shrink down to an 80px floor and then scroll on the wheel with no scrollbar drawn, and the box fades its chips out over 24px at each side that hides some (`data-dockkit-strip-scroll`, written from the box's scroll reading after each commit, scroll, and resize). Whenever the active tab or the row of chips changes, the box scrolls so the active chip stands clear of the fade band; a chip already in view moves nothing. A chip's title is never ellipsized: `TabTitle` reads its text against its box and sets `data-dockkit-tab-clipped` while the text is wider, which fades the text out over its last 16px. A chip's close control shows while the chip is active, hovered, or holds focus, over the title's last 14px, which fade under it, so the chip is the same width either way. The two slots beside the active chip draw no hairline, so the filled capsule stands between bare chips. The add, split, and chrome controls are `flex: none`, so they keep their width and place in any pane at least as wide as they are (about 130px with the chrome, 72px without). The surface's `min-width: 0` and the pane's `overflow: hidden` stop a body's longest unwrapped line from widening the pane past its box, which is what carried the controls and the body's scrollbar off-screen. +- **The chips give way; the strip's end controls never do.** The chip box is the strip's one shrinking part (`flex: 0 1 auto; min-width: 0; overflow-x: auto`): chips shrink down to an 80px floor and then scroll on the wheel with no scrollbar drawn, and the box fades its chips out over 24px at each side that hides some (`data-dockkit-strip-scroll`, written from the box's scroll reading after each commit, scroll, and resize). Whenever the active tab or the row of chips changes, the box scrolls so the active chip stands clear of the fade band; a chip already in view moves nothing. Where `DockLayout` draws a new strip for each newly selected tab, that strip first jumps to the offset the pane's previous strip recorded, so only the remaining distance animates; a strip with no recorded offset jumps straight to its first position instead of scrolling from the row's start. A chip's title is never ellipsized: `TabTitle` reads its text against its box and sets `data-dockkit-tab-clipped` while the text is wider, which fades the text out over its last 16px. A chip's close control shows while the chip is active, hovered, or holds focus, over the title's last 14px, which fade under it, so the chip is the same width either way. The two slots beside the active chip draw no hairline, so the filled capsule stands between bare chips. The add, split, and chrome controls are `flex: none`, so they keep their width and place in any pane at least as wide as they are (about 130px with the chrome, 72px without). The surface's `min-width: 0` and the pane's `overflow: hidden` stop a body's longest unwrapped line from widening the pane past its box, which is what carried the controls and the body's scrollbar off-screen. - **The chip box scrolls, but never claims a gesture.** A horizontal scroller would take press-and-move for itself and cancel the pointer; the box, the chips, and the strip set `touch-action: none` and the gesture captures the pointer, so a press-and-move on a chip is a drag and only the wheel scrolls the box. - **A split needs room for two working halves.** A pane splits into equal halves, so each half must hold what cannot shrink: the strip's fixed part — measured as the strip's width minus the chip box and the fill, which is the padding, the gaps, and every control that pane draws (its own chrome included, so the top-right pane asks more) — plus one chip at its minimum — `.tab` declares `min-width: 80px` on a content-box, so its footprint is 80px plus 10px + 10px of padding, 100px, read from a rendered chip's computed style (the stylesheet figure when none can be read); the divider between the halves takes its rendered thickness (0 — its hairline paints over the seam without taking layout room, so a body's own rules run unbroken past it). A column split, which only an edge drop makes, needs each half to hold the strip (34px) plus a 48px body: one 13px secondary line at 1.6 line-height inside 12px of the body's own insets — the pane body itself is unpadded, so a tab's body reaches the strip's bottom edge and the pane's edges and draws its own. `halvesFit` in `geometry.ts` is the arithmetic; `measure.ts` reads the rectangles after every commit and whenever the surface resizes, because the layout state carries fractions, never pixels, and the engine's planners stay that way. A pane without room keeps its split control, disabled with `splitPaneNarrow` (hidden instead under `hideSplitWhenBlocked`), and offers no edge drop zone for that axis (the release is then not a move). Under `hideSplitWhenBlocked` the split control's own footprint — its box plus the strip's gap — is left out of the fixed part: hiding the control sheds exactly that footprint from the strip, so a reading that counted it would flip with the control's visibility and re-render forever; leaving it out is also what the half being asked about would carry, since a half too narrow to split hides its own control. A pane the user narrows afterwards — a divider or the embedder's column dragged — keeps its size: the rule only decides its next split. - **Focus lands on click, not on press.** A state change between `pointerdown` and the first `pointermove` rebuilds the pressed subtree, and a replaced element cancels the pointer. It also keeps a drag from recording a redundant focus operation first. Clicks on the chips, the strip's controls, and the embedder's chrome stop at the strip: the intent each reports already decides the active pane, or is the embedder's own, so the pane's click-to-focus records nothing extra. A floating panel's grip and corner report through their gesture the same way — a press released in place is a click that raises the panel, and a drag records only the move or resize, whose operation raises it — while a press on the panel's body raises it directly. A click on the pane that is active already, a click or key on that pane's selected chip, or a press on the panel that is active and on top already, changes nothing and records nothing. diff --git a/packages/client/ui-dockkit/README.zh.md b/packages/client/ui-dockkit/README.zh.md index 85ad53eb45..3a23f282ff 100644 --- a/packages/client/ui-dockkit/README.zh.md +++ b/packages/client/ui-dockkit/README.zh.md @@ -74,7 +74,7 @@ Sidebar 使用的 `DockLayout` 在选中项变化、跨格移动与浮动期间 这些不是风格偏好;每一条都修复了在真实浏览器里发现的缺陷。 - **手势开始时捕获指针。** 不捕获的话,指针经过的任何滚动容器都可能接管手势,浏览器会将其报告为指针取消和拖动中止。捕获是加固——无论如何都由 window 监听器承载手势,所以没有该 API 的环境照样可用。 -- **chip 让位;tab 条末端的控件永不让位。** chip 盒是 tab 条里唯一会收缩的部分(`flex: 0 1 auto; min-width: 0; overflow-x: auto`):chip 先缩到 80px 下限,再在盒内随滚轮横向滚动、不画滚动条,且盒在每个藏有 chip 的一侧把 chip 在 24px 内渐隐(`data-dockkit-strip-scroll`,在每次提交、滚动与尺寸变化后由盒的滚动读数写入)。每当活动 tab 或 chip 的排列变化,盒会滚动到让活动 chip 避开渐隐带;已在视野内的 chip 不动。chip 的标题从不加省略号:`TabTitle` 拿文字宽度对照它的盒子,文字更宽时置 `data-dockkit-tab-clipped`,让文字在末端 16px 内渐隐。chip 的关闭控件在 chip 活动、悬停或持有焦点时显示,压在标题末端 14px 之上、标题在其下渐隐,因此 chip 宽度两种情况下都一样。活动 chip 两侧的槽不画细线,让填色胶囊立在裸 chip 之间。添加、分栏与 chrome 控件都是 `flex: none`,因此在任何不窄于它们自身的格里(带 chrome 约 130px,不带约 72px)都保持宽度与位置。停靠面的 `min-width: 0` 与格的 `overflow: hidden` 阻止正文里最长的不换行行把格撑出自己的盒子——正是那种情况把控件和正文滚动条推到了屏幕外。 +- **chip 让位;tab 条末端的控件永不让位。** chip 盒是 tab 条里唯一会收缩的部分(`flex: 0 1 auto; min-width: 0; overflow-x: auto`):chip 先缩到 80px 下限,再在盒内随滚轮横向滚动、不画滚动条,且盒在每个藏有 chip 的一侧把 chip 在 24px 内渐隐(`data-dockkit-strip-scroll`,在每次提交、滚动与尺寸变化后由盒的滚动读数写入)。每当活动 tab 或 chip 的排列变化,盒会滚动到让活动 chip 避开渐隐带;已在视野内的 chip 不动。`DockLayout` 为每个新选中的 tab 画出新 tab 条时,新 tab 条先跳到该格上一条 tab 条记录的偏移,只让剩余距离带动画;没有记录偏移的 tab 条直接跳到它的初始位置,而不是从排列起点滚动过去。chip 的标题从不加省略号:`TabTitle` 拿文字宽度对照它的盒子,文字更宽时置 `data-dockkit-tab-clipped`,让文字在末端 16px 内渐隐。chip 的关闭控件在 chip 活动、悬停或持有焦点时显示,压在标题末端 14px 之上、标题在其下渐隐,因此 chip 宽度两种情况下都一样。活动 chip 两侧的槽不画细线,让填色胶囊立在裸 chip 之间。添加、分栏与 chrome 控件都是 `flex: none`,因此在任何不窄于它们自身的格里(带 chrome 约 130px,不带约 72px)都保持宽度与位置。停靠面的 `min-width: 0` 与格的 `overflow: hidden` 阻止正文里最长的不换行行把格撑出自己的盒子——正是那种情况把控件和正文滚动条推到了屏幕外。 - **chip 盒会滚动,但绝不认领手势。** 横向滚动容器会把按下并移动的手势据为己有并取消指针;盒、chip 与 tab 条都设 `touch-action: none`,手势又捕获了指针,所以在 chip 上按下并移动是拖动,只有滚轮滚动盒子。 - **分栏需要给两个可用的半格留出空间。** 格被等分成两半,因此每一半都必须容得下不可收缩的部分:tab 条的固定部分——按 tab 条宽减去 chip 盒与填充条测得,即内边距、间隙以及该格绘制的每个控件(含它自己的 chrome,所以右上格要求更多)——加上一枚最小尺寸的 chip——`.tab` 在 content-box 上声明 `min-width: 80px`,所以它的足印是 80px 加 10px + 10px 内边距,即 100px,从已渲染 chip 的计算样式读取(读不到时用样式表数值);两半之间的分隔条取其渲染厚度(0——它的细线画在接缝上、不占布局空间,因此正文自己画的分隔线能不断线地穿过接缝)。纵向分栏只由边缘落下产生,它要求每一半容得下 tab 条(34px)加 48px 正文:正文自留的 12px 内边距内一行 13px、行高 1.6 的次级文字——格的正文容器本身没有内边距,tab 的正文直接贴到 tab 条底边和格的边缘,由自己留白。`geometry.ts` 里的 `halvesFit` 是算术;`measure.ts` 在每次提交后与停靠面尺寸变化时读取矩形,因为布局状态只携带比例、从不携带像素,引擎的 planner 也保持如此。没有空间的格保留分栏控件,以 `splitPaneNarrow` 禁用(开启 `hideSplitWhenBlocked` 时改为隐藏),并且在该轴上不提供边缘落区(松手就不是移动)。开启 `hideSplitWhenBlocked` 时,分栏控件自己的占位——它的盒子加 tab 条的间隙——不计入固定部分:隐藏控件让 tab 条卸下的恰是这份占位,把它算进去的读数会随控件的可见性来回翻转、无限重渲染;不计入也正是被询问的那一半会承载的量,因为窄到无法分栏的一半会隐藏自己的控件。用户随后拖窄的格——通过拖动分隔条或嵌入方的列——会保持原尺寸:规则只决定它的下一次分栏。 - **焦点落在 click 而不是按下。** 在 `pointerdown` 与第一次 `pointermove` 之间的状态变化会重建被按下的子树,而被替换的元素会取消指针。这也避免拖动先记录一条多余的焦点操作。chip、标签条各控件以及嵌入方 chrome 上的 click 都止于标签条:它们各自上报的意图已决定了活动格,或本就是嵌入方自己的事,所以格自身的点击聚焦不再多记一条。浮动面板的抓手与角柄同样通过手势上报——原地松开的按下是一次 click,抬起面板;真正的拖动只记录移动或缩放,由该操作自己抬起面板——而按在面板主体上则直接抬起它。点击本已活动的格、点击或按键选中该格本已选中的 chip,或按下本已活动且在最上层的面板,什么都不改变,也什么都不记录。 diff --git a/packages/client/ui-dockkit/src/components/TabLayout.tsx b/packages/client/ui-dockkit/src/components/TabLayout.tsx index f3a2081870..f709744fb9 100644 --- a/packages/client/ui-dockkit/src/components/TabLayout.tsx +++ b/packages/client/ui-dockkit/src/components/TabLayout.tsx @@ -3,7 +3,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { MutableRefObject, ReactNode } from 'react' import clsx from 'clsx' import type { DockIntents } from '../contract/adapter.ts' -import type { LayoutState, PaneNode, TabId, TabRecord } from '../contract/types.ts' +import type { LayoutState, PaneId, PaneNode, TabId, TabRecord } from '../contract/types.ts' import { findTabPane, floatRect, getNode, getPane } from '../engine/tree.ts' import type { SizePreview } from './PaneTree.tsx' import type { PaneCallbacks } from './render.ts' @@ -32,10 +32,12 @@ interface TabHostProps extends LayoutProps { readonly column: number readonly floats: ReturnType readonly focusRequest: MutableRefObject<{ readonly tabId: TabId; readonly origin: Element | null } | undefined> + /** Chip-box offsets by pane, read and recorded by each selected tab's strip. */ + readonly offsets: Map } /** A tab's ancestors stay identical across selection, pane moves and floating. */ -function TabHost({ state, callbacks, intents, tab, pane, column, floats, focusRequest, keepMounted, +function TabHost({ state, callbacks, intents, tab, pane, column, floats, focusRequest, offsets, keepMounted, active = true }: TabHostProps): ReactNode { const floating = pane.host === 'float' const selected = floating || pane.activeTabId === tab.id @@ -96,7 +98,7 @@ function TabHost({ state, callbacks, intents, tab, pane, column, floats, focusRe {selected && (floating ? - : )} + : )}
{visited && (retained || (active && selected)) ? callbacks.renderTab(tab) : null} @@ -118,6 +120,8 @@ function TabHost({ state, callbacks, intents, tab, pane, column, floats, focusRe export function TabLayout(props: LayoutProps): ReactNode { const { state, callbacks, preview } = props const focusRequest = useRef<{ readonly tabId: TabId; readonly origin: Element | null }>() + // Each pane's strip remounts under the newly selected tab's host. + const [offsets] = useState(() => new Map()) const tabCallbacks: PaneCallbacks = { ...callbacks, onFocusTab: (tabId) => { @@ -142,7 +146,7 @@ export function TabLayout(props: LayoutProps): ReactNode { {Object.values(state.tabs).sort((a, b) => a.id.localeCompare(b.id)).map((tab) => { const pane = findTabPane(state, tab.id) return candidate.id === pane.id)} floats={floats} focusRequest={focusRequest} /> + column={panes.findIndex(candidate => candidate.id === pane.id)} floats={floats} focusRequest={focusRequest} offsets={offsets} /> })} {panes.filter(pane => pane.tabs.length === 0).map(pane => (
diff --git a/packages/client/ui-dockkit/src/components/TabPanel.tsx b/packages/client/ui-dockkit/src/components/TabPanel.tsx index d7190754d3..0bdf80640b 100644 --- a/packages/client/ui-dockkit/src/components/TabPanel.tsx +++ b/packages/client/ui-dockkit/src/components/TabPanel.tsx @@ -26,7 +26,7 @@ import { Fragment, useLayoutEffect, useRef, useState } from 'react' import type { ReactNode, RefObject } from 'react' import clsx from 'clsx' import { IconCloseFillRegular, IconPlusOutlineRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' -import type { DockZone, LayoutState, PaneNode, TabId } from '../contract/types.ts' +import type { DockZone, LayoutState, PaneId, PaneNode, TabId } from '../contract/types.ts' import { getTab } from '../engine/tree.ts' import type { PaneCallbacks, SplitBlock } from './render.ts' import { TabMenu } from './TabMenu.tsx' @@ -173,6 +173,16 @@ function useStripScrollFades(box: RefObject, tabs: readon }, [box, tabs]) } +/** + * Move the chip box to `left` at once, overriding the stylesheet's smooth + * `scroll-behavior` for this one assignment. + */ +function jumpTo(element: HTMLElement, left: number): void { + element.style.scrollBehavior = 'auto' + element.scrollLeft = left + element.style.removeProperty('scroll-behavior') +} + /** * Bring the active chip into the chip box's view whenever the active tab or * the row of chips changes: a tab opened or selected past the box's edge, or @@ -180,23 +190,51 @@ function useStripScrollFades(box: RefObject, tabs: readon * band (24px) cleared so the chip is not under it. A chip already in view * moves nothing. Direct DOM, like the fades above: the box's scroll position * renders nothing. + * + * A box newly mounted, or newly showing another pane, first jumps to that + * pane's offset in `offsets`, so a strip that replaces another for the same + * pane continues from where that one stood and glides only the remaining + * distance. With no recorded offset the + * first placement jumps rather than glides from the row's start. The box + * records its offset in `offsets` on every scroll. */ function useActiveChipInView( box: RefObject, chips: ReadonlyMap, tabs: readonly TabId[], activeTabId: TabId | undefined, + paneId: PaneId, + offsets: Map | undefined, ): void { + // The pane the box was last placed for: a strip moved to another pane places afresh. + const placed = useRef(undefined) + useLayoutEffect(() => { + if (offsets === undefined) return undefined + const element = box.current + /* v8 ignore next -- the box is rendered unconditionally with the strip. */ + if (element === null) return undefined + const record = (): void => { offsets.set(paneId, element.scrollLeft) } + element.addEventListener('scroll', record, { passive: true }) + return () => { element.removeEventListener('scroll', record) } + }, [box, paneId, offsets]) useLayoutEffect(() => { const element = box.current const chip = activeTabId === undefined ? undefined : chips.get(activeTabId) /* v8 ignore next -- the box and the active tab's chip are rendered with the strip. */ if (element === null || chip === undefined) return + const first = placed.current !== paneId + placed.current = paneId + const recorded = first ? offsets?.get(paneId) : undefined + if (recorded !== undefined) jumpTo(element, recorded) const bounds = element.getBoundingClientRect() const rect = chip.getBoundingClientRect() - if (rect.left < bounds.left) element.scrollLeft += rect.left - bounds.left - STRIP_FADE - else if (rect.right > bounds.right) element.scrollLeft += rect.right - bounds.right + STRIP_FADE - }, [box, chips, tabs, activeTabId]) + let delta = 0 + if (rect.left < bounds.left) delta = rect.left - bounds.left - STRIP_FADE + else if (rect.right > bounds.right) delta = rect.right - bounds.right + STRIP_FADE + if (delta === 0) return + if (first && recorded === undefined) jumpTo(element, element.scrollLeft + delta) + else element.scrollLeft += delta + }, [box, chips, tabs, activeTabId, paneId, offsets]) } /** Width of the chip box's fade at a hidden side; mirrors the stylesheet's 24px. */ @@ -210,8 +248,17 @@ function splitBlockedTitle(labels: PaneCallbacks['labels'], block: SplitBlock): } } -/** @param props - pane state and gestures. @returns its tab strip without a content container. */ -export function TabStrip({ state, pane, callbacks }: TabPanelProps): ReactNode { +/** A tab strip and where it reads and records its pane's chip-box offset. */ +interface TabStripProps extends TabPanelProps { + /** + * Chip-box offsets by pane, shared by the strips that replace one another + * as a pane's selected tab changes; omitted for a strip that stays mounted. + */ + readonly offsets?: Map +} + +/** @param props - pane state, gestures and shared offsets. @returns its tab strip without a content container. */ +export function TabStrip({ state, pane, callbacks, offsets }: TabStripProps): ReactNode { // The open context menu and the chip that opened it; the menu positions // itself against that chip from its portal. const [menu, setMenu] = useState<{ readonly tabId: TabId; readonly anchor: HTMLElement } | undefined>(undefined) @@ -219,7 +266,7 @@ export function TabStrip({ state, pane, callbacks }: TabPanelProps): ReactNode { const [chips] = useState(() => new Map()) const stripTabs = useRef(null) useStripScrollFades(stripTabs, pane.tabs) - useActiveChipInView(stripTabs, chips, pane.tabs, pane.activeTabId) + useActiveChipInView(stripTabs, chips, pane.tabs, pane.activeTabId, pane.id, offsets) const block = callbacks.splitBlock(pane.id) const target = callbacks.dropTarget const stripIndex = target !== undefined && target.kind === 'strip' && target.paneId === pane.id diff --git a/packages/client/ui-dockkit/tests/tab-layout.client.spec.tsx b/packages/client/ui-dockkit/tests/tab-layout.client.spec.tsx index 7b6e9215c3..d03e9c367d 100644 --- a/packages/client/ui-dockkit/tests/tab-layout.client.spec.tsx +++ b/packages/client/ui-dockkit/tests/tab-layout.client.spec.tsx @@ -270,3 +270,91 @@ it('keeps a superseding gesture marker when an older follower detaches', () => { second() expect(element.dataset.dockkitPointer).toBeUndefined() }) + +it('continues a selected tab\'s strip from its pane\'s last offset and jumps on a first placement', () => { + let scrollLeft = 0 + // Each assignment with the inline `scroll-behavior` it ran under: `auto` jumps, empty leaves the stylesheet's glide. + const writes: (readonly [number, string])[] = [] + const descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollLeft') + Object.defineProperty(Element.prototype, 'scrollLeft', { + configurable: true, + get() { return scrollLeft }, + set(this: HTMLElement, value: number) { + scrollLeft = value + writes.push([value, this.style.scrollBehavior]) + }, + }) + const rect = (left: number, width: number): DOMRect => ({ + left, right: left + width, top: 0, bottom: 28, x: left, y: 0, width, height: 28, toJSON: () => ({}), + }) + // The chip box spans 0..200; the seeded chip sits at 40..130 and the opened one where `openedAt` says. + const controller = seededController() + const seeded = getPane(controller.getSnapshot().state, controller.getSnapshot().state.activePaneId).activeTabId + if (seeded === undefined) throw new Error('expected the seeded tab') + const opened = controller.openContent({ kind: 'file', contentId: 'file:b', title: 'B' }) + let openedAt = 250 + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this.hasAttribute('data-dockkit-strip-tabs')) return rect(0, 200) + if (this.getAttribute('data-dockkit-tab') === opened) return rect(openedAt, 90) + if (this.hasAttribute('data-dockkit-tab')) return rect(40, 90) + return rect(0, 0) + }) + try { + const h = mounted(controller) + const scrolled = (): void => { fireEvent.scroll(h.node('[data-dockkit-strip-tabs]')) } + // The first strip has no recorded offset: it jumps to the chip. + expect(writes).toEqual([[164, 'auto']]) + scrolled() + // The seeded tab's strip starts where the previous one stood, where its chip is in view. + scrollLeft = 0 + fireEvent.click(h.node(`[data-dockkit-tab="${seeded}"]`)) + expect(writes.slice(1)).toEqual([[164, 'auto']]) + scrolled() + // Back on the opened tab, now 30 past the left edge: restored, then glided the rest. + openedAt = -30 + scrollLeft = 0 + fireEvent.click(h.node(`[data-dockkit-tab="${opened}"]`)) + expect(writes.slice(2)).toEqual([[164, 'auto'], [164 - 30 - 24, '']]) + } finally { + if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, 'scrollLeft') + else Object.defineProperty(Element.prototype, 'scrollLeft', descriptor) + } +}) + +it('places a selected tab\'s strip moved to another pane from that pane\'s last offset', () => { + // Offsets per chip box, with the inline `scroll-behavior` of each assignment. + const offsets = new WeakMap() + const writes: (readonly [string | undefined, number, string])[] = [] + const descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollLeft') + Object.defineProperty(Element.prototype, 'scrollLeft', { + configurable: true, + get(this: Element) { return offsets.get(this) ?? 0 }, + set(this: HTMLElement, value: number) { + offsets.set(this, value) + writes.push([this.dataset.dockkitStripTabs, value, this.style.scrollBehavior]) + }, + }) + try { + const controller = seededController() + controller.setExpanded(true) + const moved = controller.openContent({ kind: 'file', contentId: 'file:a', title: 'A' }) + controller.openContent({ kind: 'file', contentId: 'file:b', title: 'B' }) + const h = mounted(controller) + act(() => { controller.splitPane(); h.redraw() }) + const state = controller.getSnapshot().state + const split = getNode(state, state.rootId) + if (split.kind !== 'split') throw new Error('expected a split') + const target = getPane(state, split.children[1]!).id + act(() => { controller.focusTab(moved); h.redraw() }) + const strip = h.node(`[data-dockkit-strip-tabs="${target}"]`) + offsets.set(strip, 70) + fireEvent.scroll(strip) + writes.length = 0 + act(() => { controller.placeTab(moved, target, 0); h.redraw() }) + expect(h.node(`[data-dockkit-strip-tabs="${target}"] [data-dockkit-tab="${moved}"]`).getAttribute('aria-selected')).toBe('true') + expect(writes).toEqual([[target, 70, 'auto']]) + } finally { + if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, 'scrollLeft') + else Object.defineProperty(Element.prototype, 'scrollLeft', descriptor) + } +}) From a43a4bb9ee335627227f95f6eba09dcadbddf5fc Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:50:31 +0800 Subject: [PATCH 33/46] fix(desktop): supervise optional Windows runner network process --- .github/workflows/ci-windows.yml | 8 +- .../run-windows-runner-network-process.ps1 | 138 +++++++++++++++++ .../scripts/run-windows-runner-task.ps1 | 8 +- .../start-windows-packaging-runner.ps1 | 4 + .../scripts/windows-runner-network.ps1 | 80 ++++++++++ apps/desktop/scripts/windows-runner-task.ps1 | 20 ++- .../tests/windows-runner-network-process.ps1 | 139 ++++++++++++++++++ apps/desktop/tests/windows-runner-network.ps1 | 117 +++++++++++++++ apps/desktop/tests/windows-runner-task.ps1 | 2 +- apps/desktop/windows-signing/README.i18n.yaml | 7 +- apps/desktop/windows-signing/README.md | 10 ++ apps/desktop/windows-signing/README.zh.md | 10 ++ 12 files changed, 534 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/scripts/run-windows-runner-network-process.ps1 create mode 100644 apps/desktop/scripts/windows-runner-network.ps1 create mode 100644 apps/desktop/tests/windows-runner-network-process.ps1 create mode 100644 apps/desktop/tests/windows-runner-network.ps1 diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index fa2473a3a3..183a43dc9d 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -76,7 +76,7 @@ jobs: - name: Parse Windows runner supervision scripts shell: pwsh run: | - foreach ($path in @('apps/desktop/scripts/windows-runner-task.ps1', 'apps/desktop/scripts/run-windows-runner-task.ps1', 'apps/desktop/tests/windows-runner-task.ps1', 'apps/desktop/tests/windows-runner-launcher.ps1')) { + foreach ($path in @('apps/desktop/scripts/windows-runner-task.ps1', 'apps/desktop/scripts/run-windows-runner-task.ps1', 'apps/desktop/scripts/start-windows-packaging-runner.ps1', 'apps/desktop/scripts/windows-runner-network.ps1', 'apps/desktop/scripts/run-windows-runner-network-process.ps1', 'apps/desktop/tests/windows-runner-task.ps1', 'apps/desktop/tests/windows-runner-launcher.ps1', 'apps/desktop/tests/windows-runner-network.ps1', 'apps/desktop/tests/windows-runner-network-process.ps1')) { $tokens = $null $parseErrors = $null $null = [Management.Automation.Language.Parser]::ParseFile((Join-Path $env:GITHUB_WORKSPACE $path), [ref]$tokens, [ref]$parseErrors) @@ -85,6 +85,12 @@ jobs: - name: Verify windowless Windows runner launcher shell: pwsh run: ./apps/desktop/tests/windows-runner-launcher.ps1 + - name: Verify Windows runner network supervision + shell: pwsh + run: | + ./apps/desktop/tests/windows-runner-network.ps1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + ./apps/desktop/tests/windows-runner-network-process.ps1 - name: Run Windows observational gates on built artifacts id: observational continue-on-error: true diff --git a/apps/desktop/scripts/run-windows-runner-network-process.ps1 b/apps/desktop/scripts/run-windows-runner-network-process.ps1 new file mode 100644 index 0000000000..5129c6c860 --- /dev/null +++ b/apps/desktop/scripts/run-windows-runner-network-process.ps1 @@ -0,0 +1,138 @@ +# Own one operator-configured network process until the registered runner exits. +param([Parameter(Mandatory=$true)][string]$RunnerRoot, [Parameter(Mandatory=$true)][string]$ConfigRoot, [Parameter(Mandatory=$true)][string]$NetworkRoot, [Parameter(Mandatory=$true)][string]$ReadinessScript, [Parameter(Mandatory=$true)][string]$StartScript, [switch]$CheckOnly) +$ErrorActionPreference = 'Stop' +$network = $NetworkRoot +$lock = $null +$process = $null +$started = $false +$runner = $null +$runnerStarted = $false +$restarts = 0 +$recordPath = Join-Path $network 'process.json' +$code = 1 +try { + foreach ($directory in @($RunnerRoot,$ConfigRoot,$NetworkRoot)) { + if ($directory -notmatch '^[A-Za-z]:[\\/]' -or -not (Test-Path -LiteralPath $directory -PathType Container)) { throw 'An existing absolute local directory is required.' } + } + foreach ($script in @($ReadinessScript,$StartScript)) { + if ($script -notmatch '^[A-Za-z]:[\\/]' -or -not (Test-Path -LiteralPath $script -PathType Leaf)) { throw 'An existing absolute local script is required.' } + } + $lock = [IO.File]::Open((Join-Path $network 'supervisor.lock'), 'OpenOrCreate', 'ReadWrite', 'None') + $launch = Get-Content (Join-Path $network 'launch.json') -Raw | ConvertFrom-Json -AsHashtable + if ($launch -isnot [Collections.IDictionary] -or $launch.Count -ne 3 -or @($launch.Keys | Where-Object { $_ -cnotin @('executable','arguments','restartDelaySeconds') }).Count) { throw 'Invalid network launch configuration.' } + if (($launch.restartDelaySeconds -isnot [long] -and $launch.restartDelaySeconds -isnot [int]) -or $launch.restartDelaySeconds -lt 1 -or $launch.restartDelaySeconds -gt 3600) { throw 'Invalid network restart interval.' } + if ($launch.executable -isnot [string] -or $launch.arguments -isnot [array] -or @($launch.arguments | Where-Object { $_ -isnot [string] -or $_.Contains([char]0) }).Count) { throw 'Invalid network executable or arguments.' } + if (-not [IO.Path]::IsPathFullyQualified($launch.executable) -or -not (Test-Path -LiteralPath $launch.executable -PathType Leaf)) { throw 'Network executable unavailable.' } + $launch.executable = [IO.Path]::GetFullPath($launch.executable) + if (Test-Path -LiteralPath $recordPath) { + $record = Get-Content -LiteralPath $recordPath -Raw | ConvertFrom-Json + if ($record.pid -isnot [long] -and $record.pid -isnot [int]) { throw 'Invalid network process record.' } + if ($record.pid -le 0 -or $record.started -isnot [long] -or $record.started -le 0) { throw 'Invalid network process record.' } + $stale = Get-Process -Id $record.pid -ErrorAction SilentlyContinue + if ($stale) { + try { + if (-not $stale.StartTime) { throw 'Owned network process identity unavailable.' } + if ($stale.StartTime.ToUniversalTime().Ticks -eq $record.started) { + if ($stale.Path -ne $launch.executable) { throw 'Owned network process identity unavailable.' } + $stale.Kill($true) + if (-not $stale.WaitForExit(10000)) { throw 'Owned network process did not stop.' } + } + } finally { $stale.Dispose() } + } + Remove-Item -LiteralPath $recordPath + } + $info = [Diagnostics.ProcessStartInfo]::new($launch.executable) + $info.UseShellExecute = $false + $info.CreateNoWindow = $true + $info.WorkingDirectory = $network + foreach ($name in @('HTTP_PROXY','HTTPS_PROXY','ALL_PROXY','http_proxy','https_proxy','all_proxy')) { $null = $info.Environment.Remove($name) } + # Provider output can include private routes; it must never enter runner logs. + $info.RedirectStandardOutput = $true + $info.RedirectStandardError = $true + foreach ($argument in $launch.arguments) { $info.ArgumentList.Add($argument) } + $process = [Diagnostics.Process]::new() + $process.StartInfo = $info + if (-not $process.Start()) { throw 'Network process did not start.' } + $started = $true + $stdout = $process.StandardOutput.BaseStream.CopyToAsync([IO.Stream]::Null) + $stderr = $process.StandardError.BaseStream.CopyToAsync([IO.Stream]::Null) + @{pid=$process.Id;started=$process.StartTime.ToUniversalTime().Ticks} | ConvertTo-Json | Set-Content -LiteralPath $recordPath + & (Join-Path $PSHOME 'pwsh.exe') -NoProfile -NonInteractive -File $ReadinessScript -RunnerRoot $RunnerRoot -ConfigRoot $ConfigRoot -CheckOnly + if ($LASTEXITCODE -ne 0 -or $process.HasExited) { throw 'Network readiness failed; listener was not started.' } + if ($CheckOnly) { $code = 0 } else { + $runnerInfo = [Diagnostics.ProcessStartInfo]::new((Join-Path $PSHOME 'pwsh.exe')) + $runnerInfo.UseShellExecute = $false + $runnerInfo.CreateNoWindow = $true + $runnerInfo.RedirectStandardOutput = $true + $runnerInfo.RedirectStandardError = $true + foreach ($argument in @('-NoProfile','-NonInteractive','-File',$StartScript,'-RunnerRoot',$RunnerRoot,'-ConfigRoot',$ConfigRoot)) { $runnerInfo.ArgumentList.Add($argument) } + $runner = [Diagnostics.Process]::new() + $runner.StartInfo = $runnerInfo + if (-not $runner.Start()) { throw 'Runner process did not start.' } + $runnerStarted = $true + $runnerOut = $runner.StandardOutput.BaseStream.CopyToAsync([Console]::OpenStandardOutput()) + $runnerErr = $runner.StandardError.BaseStream.CopyToAsync([Console]::OpenStandardError()) + while (-not $runner.WaitForExit(1000)) { + if ($started -and -not $process.HasExited) { continue } + if ($started) { + Write-Output "Network process exited (code $($process.ExitCode)); runner retained. Inspect local network configuration if recovery repeats." + $started = $false + Remove-Item -LiteralPath $recordPath + $process.Dispose() + $process = $null + } + # The runner's lifetime owns recovery; waiting on it also cancels the retry delay. + if ($runner.WaitForExit($launch.restartDelaySeconds * 1000)) { break } + $process = [Diagnostics.Process]::new() + $process.StartInfo = $info + try { $started = $process.Start() } + catch { + # Launch errors can contain private arguments or paths. + $started = $false + } + if (-not $started) { + $process.Dispose() + $process = $null + Write-Output 'Network process restart failed; runner retained. Inspect the local executable and configuration; recovery will retry.' + continue + } + $stdout = $process.StandardOutput.BaseStream.CopyToAsync([IO.Stream]::Null) + $stderr = $process.StandardError.BaseStream.CopyToAsync([IO.Stream]::Null) + @{pid=$process.Id;started=$process.StartTime.ToUniversalTime().Ticks} | ConvertTo-Json | Set-Content -LiteralPath $recordPath + $restarts++ + Write-Output "Network process restarted (attempt $restarts); runner retained. Connectivity recovery is handled by the runner." + } + $code = $runner.ExitCode + } +} catch { + # Do not expose provider paths, arguments, configuration or raw error messages. + [Console]::Error.WriteLine('Private network supervision failed; inspect local state. No job was retried.') + if ($runnerStarted -and -not $runner.HasExited) { + [Console]::Error.WriteLine('Network recovery stopped; the existing runner remains running until it exits. Inspect local supervision state before maintenance.') + $runner.WaitForExit() + } +} finally { + if ($runnerStarted) { + try { + if (-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($runnerOut,$runnerErr), 5000)) { throw 'Output drain timed out.' } + } catch { + $code = 1 + [Console]::Error.WriteLine('Runner output drain failed or timed out; output may be incomplete.') + } + } + if ($runner) { $runner.Dispose() } + try { + if ($started) { + if (-not $process.HasExited) { $process.Kill($true) } + if (-not $process.WaitForExit(10000)) { throw 'Network cleanup did not complete.' } + if (Test-Path -LiteralPath $recordPath) { Remove-Item -LiteralPath $recordPath } + } + } catch { + $code = 1 + [Console]::Error.WriteLine('Owned network cleanup failed; inspect local process state before restarting.') + } finally { + if ($process) { $process.Dispose() } + if ($lock) { $lock.Dispose() } + } +} +exit $code diff --git a/apps/desktop/scripts/run-windows-runner-task.ps1 b/apps/desktop/scripts/run-windows-runner-task.ps1 index 339350377a..56371e970c 100644 --- a/apps/desktop/scripts/run-windows-runner-task.ps1 +++ b/apps/desktop/scripts/run-windows-runner-task.ps1 @@ -15,7 +15,13 @@ try { exit 1 } Set-Content -LiteralPath $attemptFile -Value ($attempts + 1) - & (Join-Path $PSHOME 'pwsh.exe') -NoProfile -NonInteractive -File (Join-Path $PSScriptRoot 'start.ps1') -RunnerRoot $config.RunnerRoot -ConfigRoot $config.ConfigRoot *> $log + $startup = Join-Path $PSScriptRoot 'start.ps1' + if ($config.NetworkRoot) { + if (-not (Test-Path -LiteralPath (Join-Path $config.ConfigRoot 'runner-network.json') -PathType Leaf)) { throw 'Owned network supervision requires runner-network.json.' } + & (Join-Path $PSHOME 'pwsh.exe') -NoProfile -NonInteractive -File (Join-Path $PSScriptRoot 'run-windows-runner-network-process.ps1') -RunnerRoot $config.RunnerRoot -ConfigRoot $config.ConfigRoot -NetworkRoot $config.NetworkRoot -ReadinessScript $startup -StartScript $startup *> $log + } else { + & (Join-Path $PSHOME 'pwsh.exe') -NoProfile -NonInteractive -File $startup -RunnerRoot $config.RunnerRoot -ConfigRoot $config.ConfigRoot *> $log + } $code = $LASTEXITCODE Add-Content -LiteralPath $log -Value "Runner launcher exited with code $code." # An unexpected exit is a task failure even when run.cmd hides the listener's nonzero status; the log retains its code. diff --git a/apps/desktop/scripts/start-windows-packaging-runner.ps1 b/apps/desktop/scripts/start-windows-packaging-runner.ps1 index 13251a110a..a024296024 100644 --- a/apps/desktop/scripts/start-windows-packaging-runner.ps1 +++ b/apps/desktop/scripts/start-windows-packaging-runner.ps1 @@ -16,6 +16,9 @@ if ($drive.DriveType -ne [IO.DriveType]::Fixed) { throw 'ConfigRoot requires a f $env:DSH_PACKAGING_CONFIG_DIR=[IO.Path]::GetFullPath($ConfigRoot) $env:DSH_TELEMETRY_DISABLED='1' $env:POWERSHELL_TELEMETRY_OPTOUT='1' +. (Join-Path $PSScriptRoot 'windows-runner-network.ps1') +$network = Read-RunnerNetwork $ConfigRoot +Set-RunnerNetwork $network foreach($tool in @('pwsh','node','git','tar','python')) { $resolved=Get-Command $tool -CommandType Application -ErrorAction Stop | Select-Object -First 1 Write-Output ('Tool: '+$tool+' -> '+$resolved.Source) @@ -31,6 +34,7 @@ $vswhere=Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio/Installer/v $visualStudio=& $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath if($LASTEXITCODE -ne 0 -or -not $visualStudio){throw 'Visual C++ tools unavailable'} Write-Output 'Visual C++ tools located.' +Wait-RunnerNetwork $network if($CheckOnly){Write-Output 'Runner environment check passed.'; exit 0} if(Get-Process -Name Runner.Listener -ErrorAction SilentlyContinue){throw 'A runner listener is already running; inspect before starting another.'} Set-Location $RunnerRoot diff --git a/apps/desktop/scripts/windows-runner-network.ps1 b/apps/desktop/scripts/windows-runner-network.ps1 new file mode 100644 index 0000000000..9ab2491901 --- /dev/null +++ b/apps/desktop/scripts/windows-runner-network.ps1 @@ -0,0 +1,80 @@ +# Load operator-owned network settings without evaluating code or logging their values. +function Read-RunnerNetwork([string]$ConfigRoot) { + $path = Join-Path $ConfigRoot 'runner-network.json' + if (-not (Test-Path -LiteralPath $path)) { return $null } + try { $config = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json -AsHashtable -ErrorAction Stop } + catch { throw 'Cannot read runner-network.json; inspect its JSON locally.' } + $keys = @('proxyUrl','noProxy','serviceName','probeUrl','attempts','timeoutSeconds','retrySeconds') + if ($config -isnot [Collections.IDictionary] -or $config.Count -ne $keys.Count -or @($config.Keys | Where-Object { $_ -cnotin $keys }).Count) { + throw 'runner-network.json requires exactly proxyUrl, noProxy, serviceName, probeUrl, attempts, timeoutSeconds and retrySeconds.' + } + foreach ($key in @('proxyUrl','noProxy','serviceName','probeUrl')) { + if ($config[$key] -isnot [string] -or $config[$key] -match '[\r\n\x00]') { throw 'Invalid network string setting; inspect runner-network.json locally.' } + } + if ([Management.Automation.WildcardPattern]::ContainsWildcardCharacters($config.serviceName)) { throw 'Network service must be an exact name.' } + foreach ($key in @('attempts','timeoutSeconds','retrySeconds')) { + if ($config[$key] -isnot [long] -and $config[$key] -isnot [int]) { throw 'Network budgets must be integers.' } + if ($config[$key] -lt 1 -or $config[$key] -gt 60) { throw 'Network budgets must be between 1 and 60.' } + } + foreach ($key in @('proxyUrl','probeUrl')) { + if ($key -eq 'proxyUrl' -and $config[$key] -eq '') { continue } + $uri = $null + if (-not [Uri]::TryCreate($config[$key], [UriKind]::Absolute, [ref]$uri) -or $uri.Scheme -notin @('http','https') -or $uri.UserInfo -or $uri.Query -or $uri.Fragment) { + throw 'Network URLs must be absolute HTTP(S) URLs without credentials, queries or fragments.' + } + if ($key -eq 'proxyUrl' -and $uri.AbsolutePath -ne '/') { throw 'Proxy URL must not contain a path.' } + } + return $config +} + +# Called after the runner environment is scrubbed; only declared network values reach child jobs. +function Set-RunnerNetwork($Config) { + if ($null -eq $Config) { return } + [Environment]::SetEnvironmentVariable('HTTP_PROXY', $Config.proxyUrl, 'Process') + [Environment]::SetEnvironmentVariable('HTTPS_PROXY', $Config.proxyUrl, 'Process') + [Environment]::SetEnvironmentVariable('NO_PROXY', $Config.noProxy, 'Process') + [Environment]::SetEnvironmentVariable('NODE_USE_ENV_PROXY', '1', 'Process') +} + +# Only probe retries are allowed here. Service recovery belongs to its installed Windows service. +function Wait-RunnerNetwork($Config) { + if ($null -eq $Config) { return } + $info = [Diagnostics.ProcessStartInfo]::new((Get-Command node -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source) + $info.UseShellExecute = $false + $info.CreateNoWindow = $true + $info.RedirectStandardOutput = $true + $info.RedirectStandardError = $true + $info.Environment['DSH_NETWORK_PROBE_URL'] = $Config.probeUrl + $info.Environment['DSH_NETWORK_PROBE_TIMEOUT'] = [string]($Config.timeoutSeconds * 1000) + $info.ArgumentList.Add('--input-type=module') + $info.ArgumentList.Add('-e') + $info.ArgumentList.Add('try { const r = await fetch(process.env.DSH_NETWORK_PROBE_URL, { method: "HEAD", redirect: "error", signal: AbortSignal.timeout(Number(process.env.DSH_NETWORK_PROBE_TIMEOUT)) }); await r.body?.cancel(); process.exit(r.ok ? 0 : 1); } catch { process.exit(1); }') + for ($attempt = 1; $attempt -le $Config.attempts; $attempt++) { + $ready = $true + if ($Config.serviceName) { + try { $service = Get-Service -Name $Config.serviceName -ErrorAction Stop } + catch { throw 'Configured network service is unavailable; inspect its installation locally.' } + $ready = $service.Status -eq 'Running' + } + if ($ready) { + $process = [Diagnostics.Process]::new() + $process.StartInfo = $info + try { + if (-not $process.Start()) { throw 'Cannot launch network probe.' } + $out = $process.StandardOutput.ReadToEndAsync() + $err = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit(($Config.timeoutSeconds + 5) * 1000)) { + $process.Kill($true) + $process.WaitForExit() + $ready = $false + } else { $ready = $process.ExitCode -eq 0 } + $null = $out.GetAwaiter().GetResult() + $null = $err.GetAwaiter().GetResult() + } finally { $process.Dispose() } + } + if ($ready) { Write-Output 'Runner network dependency ready (Node HEAD probe).'; return } + Write-Output "Runner network dependency not ready (probe $attempt/$($Config.attempts))." + if ($attempt -lt $Config.attempts) { Start-Sleep -Seconds $Config.retrySeconds } + } + throw 'Runner network dependency unavailable; runner was not started. Inspect local service and network configuration. No job or signing retry was requested.' +} diff --git a/apps/desktop/scripts/windows-runner-task.ps1 b/apps/desktop/scripts/windows-runner-task.ps1 index 47efba43e0..d1bbd02537 100644 --- a/apps/desktop/scripts/windows-runner-task.ps1 +++ b/apps/desktop/scripts/windows-runner-task.ps1 @@ -4,13 +4,14 @@ param( [Parameter(Mandatory=$true)][ValidateSet('install','start','stop','status','uninstall')][string]$Action, [Parameter(Mandatory=$true)][string]$RunnerRoot, [string]$ConfigRoot, + [string]$NetworkRoot, [ValidateRange(1,1440)][int]$RestartMinutes = 1, [ValidateRange(1,999)][int]$RestartCount = 10 ) $ErrorActionPreference = 'Stop' if (-not $IsWindows) { throw 'This command requires PowerShell 7 on Windows.' } -if ($Action -ne 'install' -and @('ConfigRoot','RestartMinutes','RestartCount').Where({ $PSBoundParameters.ContainsKey($_) }).Count) { - throw 'ConfigRoot, RestartMinutes and RestartCount apply only to install; reinstall to change them.' +if ($Action -ne 'install' -and @('ConfigRoot','NetworkRoot','RestartMinutes','RestartCount').Where({ $PSBoundParameters.ContainsKey($_) }).Count) { + throw 'ConfigRoot, NetworkRoot, RestartMinutes and RestartCount apply only to install; reinstall to change them.' } if ($RunnerRoot -notmatch '^[A-Za-z]:[\\/]') { throw 'RunnerRoot must be an absolute local directory.' } $RunnerRoot = [IO.Path]::GetFullPath($RunnerRoot).TrimEnd('\','/') @@ -30,7 +31,15 @@ if ($Action -eq 'install') { if (-not $ConfigRoot -or $ConfigRoot -notmatch '^[A-Za-z]:[\\/]') { throw 'Install requires an absolute ConfigRoot.' } $ConfigRoot = [IO.Path]::GetFullPath($ConfigRoot) $pwsh = Join-Path $PSHOME 'pwsh.exe' - & $pwsh -NoProfile -File (Join-Path $PSScriptRoot 'start-windows-packaging-runner.ps1') -RunnerRoot $RunnerRoot -ConfigRoot $ConfigRoot -CheckOnly + $startup = Join-Path $PSScriptRoot 'start-windows-packaging-runner.ps1' + if ($PSBoundParameters.ContainsKey('NetworkRoot')) { + if ($NetworkRoot -notmatch '^[A-Za-z]:[\\/]') { throw 'NetworkRoot must identify an existing absolute local directory.' } + if (-not (Test-Path -LiteralPath (Join-Path $ConfigRoot 'runner-network.json') -PathType Leaf)) { throw 'Owned network supervision requires runner-network.json.' } + $NetworkRoot = [IO.Path]::GetFullPath($NetworkRoot) + & $pwsh -NoProfile -File (Join-Path $PSScriptRoot 'run-windows-runner-network-process.ps1') -RunnerRoot $RunnerRoot -ConfigRoot $ConfigRoot -NetworkRoot $NetworkRoot -ReadinessScript $startup -StartScript $startup -CheckOnly + } else { + & $pwsh -NoProfile -File $startup -RunnerRoot $RunnerRoot -ConfigRoot $ConfigRoot -CheckOnly + } if ($LASTEXITCODE -ne 0) { throw 'Runner preflight failed; no task was installed.' } if (Test-Path -LiteralPath $serviceDir) { if ((Get-Item -LiteralPath $serviceDir).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Task directory cannot be a link.' } @@ -44,7 +53,10 @@ if ($Action -eq 'install') { if ($LASTEXITCODE -ne 0) { throw 'Runner launcher compilation failed; verify .NET Framework 4.5+ is installed. No task was installed.' } Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'start-windows-packaging-runner.ps1') -Destination (Join-Path $serviceDir 'start.ps1') Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'run-windows-runner-task.ps1') -Destination (Join-Path $serviceDir 'run.ps1') - @{ RunnerRoot = $RunnerRoot; ConfigRoot = $ConfigRoot; TaskName = $taskName; RestartCount = $RestartCount } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $serviceDir 'config.json') -Encoding utf8 + foreach ($helper in @('windows-runner-network.ps1','run-windows-runner-network-process.ps1')) { + Copy-Item -LiteralPath (Join-Path $PSScriptRoot $helper) -Destination (Join-Path $serviceDir $helper) + } + @{ RunnerRoot = $RunnerRoot; ConfigRoot = $ConfigRoot; NetworkRoot = $NetworkRoot; TaskName = $taskName; RestartCount = $RestartCount } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $serviceDir 'config.json') -Encoding utf8 Set-Content -LiteralPath (Join-Path $serviceDir 'attempts.txt') -Value '0' $execution = New-ScheduledTaskAction -Execute $launcher -Argument ('"' + $pwsh + '"') -WorkingDirectory $RunnerRoot $principal = New-ScheduledTaskPrincipal -UserId $sid -LogonType Interactive -RunLevel Limited diff --git a/apps/desktop/tests/windows-runner-network-process.ps1 b/apps/desktop/tests/windows-runner-network-process.ps1 new file mode 100644 index 0000000000..3c908fd039 --- /dev/null +++ b/apps/desktop/tests/windows-runner-network-process.ps1 @@ -0,0 +1,139 @@ +# Fake local processes only; no network or signing hardware. +$ErrorActionPreference = 'Stop' +$root = Join-Path ([IO.Path]::GetTempPath()) ('demo-network-' + [guid]::NewGuid().ToString('N')) +$null = New-Item -ItemType Directory $root +try { + foreach ($case in @('ready','not-ready','locked','missing-executable','stale','reused','invalid-config','invalid-delay','bad-record','provider-exit','runner-failure','check-only','recovery','task-route')) { + $base = Join-Path $root $case + $runner = Join-Path $base 'runner' + $network = Join-Path $base 'network' + $null = New-Item -ItemType Directory -Path $network,(Join-Path $runner 'dsh-task') -Force + @' +Set-Content (Join-Path $PSScriptRoot 'child-pid') $PID +Write-Output 'PRIVATE-OUTPUT-MUST-NOT-LEAK' +Start-Sleep -Seconds 60 +'@ | Set-Content (Join-Path $network 'fake.ps1') + @' +param([string]$RunnerRoot,[string]$ConfigRoot) +$network = Join-Path (Split-Path $PSScriptRoot) 'network' +$deadline = [datetime]::UtcNow.AddSeconds(10) +while (-not (Test-Path (Join-Path $network 'child-pid'))) { + if ([datetime]::UtcNow -ge $deadline) { exit 9 } + Start-Sleep -Milliseconds 20 +} +if (Test-Path (Join-Path $network 'fail')) { exit 1 } +exit 0 +'@ | Set-Content (Join-Path $runner 'cold-boot-smoke.ps1') + "Set-Content (Join-Path `$PSScriptRoot 'listened') yes; exit 0" | Set-Content (Join-Path $runner 'dsh-task/start-runner.ps1') + if ($case -eq 'runner-failure') { Set-Content (Join-Path $runner 'dsh-task/start-runner.ps1') "Set-Content (Join-Path `$PSScriptRoot 'listened') yes; exit 7" } + if ($case -in @('recovery','task-route')) { + @' +param([string]$RunnerRoot,[string]$ConfigRoot,[switch]$CheckOnly) +$ErrorActionPreference = 'Stop' +$network = Join-Path $ConfigRoot 'network' +if ($CheckOnly) { + $deadline = [datetime]::UtcNow.AddSeconds(30) + while (-not (Test-Path (Join-Path $network 'child-pid'))) { + if ([datetime]::UtcNow -ge $deadline) { exit 9 } + Start-Sleep -Milliseconds 20 + } + exit 0 +} +Set-Content (Join-Path $PSScriptRoot 'listened') $PID +Write-Output 'RUNNER-STDOUT' +[Console]::Error.WriteLine('RUNNER-STDERR') +for ($iteration = 0; $iteration -lt 2; $iteration++) { + $previous = [int](Get-Content (Join-Path $network 'child-pid')) + $child = Get-Process -Id $previous + try { $child.Kill(); $child.WaitForExit() } finally { $child.Dispose() } + $deadline = [datetime]::UtcNow.AddSeconds(30) + do { + if ([datetime]::UtcNow -ge $deadline) { throw 'Network process was not recovered.' } + Start-Sleep -Milliseconds 50 + $next = Get-Content (Join-Path $network 'child-pid') -Raw + } while (-not $next -or [int]$next -eq $previous) + if ([int](Get-Content (Join-Path $PSScriptRoot 'listened')) -ne $PID) { throw 'Runner was replaced during recovery.' } +} +Write-Output 'RUNNER-SURVIVED-RECOVERY' +exit 0 +'@ | Set-Content (Join-Path $runner 'dsh-task/start-runner.ps1') + } + if ($case -eq 'provider-exit') { + Set-Content (Join-Path $network 'fake.ps1') 'Set-Content (Join-Path $PSScriptRoot "child-pid") $PID; exit 0' + @' +$network = Join-Path (Split-Path $PSScriptRoot) 'network' +$deadline = [datetime]::UtcNow.AddSeconds(10) +do { + if ([datetime]::UtcNow -ge $deadline) { throw 'Provider exit barrier timed out.' } + Start-Sleep -Milliseconds 20 + $record = Join-Path $network 'child-pid' +} while (-not (Test-Path $record) -or (Get-Process -Id ([int](Get-Content $record)) -ErrorAction SilentlyContinue)) +exit 0 +'@ | Set-Content (Join-Path $runner 'cold-boot-smoke.ps1') + } + $exe = if ($case -eq 'missing-executable') { Join-Path $network 'missing.exe' } else { Join-Path $PSHOME 'pwsh.exe' } + @{executable=$exe;arguments=@('-NoProfile','-File',(Join-Path $network 'fake.ps1'));restartDelaySeconds=1} | ConvertTo-Json | Set-Content (Join-Path $network 'launch.json') + if ($case -eq 'invalid-config') { Set-Content (Join-Path $network 'launch.json') '{"executable":"PRIVATE-OUTPUT","arguments":[],"unknown":true}' } + if ($case -eq 'invalid-delay') { @{executable=$exe;arguments=@();restartDelaySeconds=0} | ConvertTo-Json | Set-Content (Join-Path $network 'launch.json') } + if ($case -eq 'bad-record') { Set-Content (Join-Path $network 'process.json') '{"pid":0,"started":1}' } + if ($case -eq 'not-ready') { Set-Content (Join-Path $network 'fail') yes } + $held = if ($case -eq 'locked') { [IO.File]::Open((Join-Path $network 'supervisor.lock'),'OpenOrCreate','ReadWrite','None') } else { $null } + $stale = $null + try { + if ($case -in @('stale','reused')) { + $info = [Diagnostics.ProcessStartInfo]::new($exe) + $info.UseShellExecute = $false + $info.CreateNoWindow = $true + foreach ($argument in @('-NoProfile','-Command','Start-Sleep -Seconds 60')) { $info.ArgumentList.Add($argument) } + $stale = [Diagnostics.Process]::Start($info) + @{pid=$stale.Id;started=($stale.StartTime.ToUniversalTime().Ticks + $(if($case -eq 'reused'){1}else{0}))} | ConvertTo-Json | Set-Content (Join-Path $network 'process.json') + } + [string[]]$options = @(if ($case -eq 'check-only') { '-CheckOnly' }) + if ($case -eq 'task-route') { + $taskRoot = Join-Path $runner 'dsh-task' + Copy-Item (Join-Path $PSScriptRoot '../scripts/run-windows-runner-task.ps1') (Join-Path $taskRoot 'run.ps1') + Copy-Item (Join-Path $PSScriptRoot '../scripts/run-windows-runner-network-process.ps1') $taskRoot + Copy-Item (Join-Path $taskRoot 'start-runner.ps1') (Join-Path $taskRoot 'start.ps1') + @{RunnerRoot=$runner;ConfigRoot=$base;NetworkRoot=$network;RestartCount=1} | ConvertTo-Json | Set-Content (Join-Path $taskRoot 'config.json') + Set-Content (Join-Path $taskRoot 'attempts.txt') 0 + Set-Content (Join-Path $base 'runner-network.json') '{}' + & (Join-Path $PSHOME 'pwsh.exe') -NoProfile -File (Join-Path $taskRoot 'run.ps1') + if ($LASTEXITCODE -ne 1) { throw 'Task wrapper must retain its unexpected-exit result.' } + $output = Get-Content (Get-ChildItem $taskRoot -Filter 'run-*.log').FullName -Raw + if ([int](Get-Content (Join-Path $taskRoot 'attempts.txt')) -ne 1) { throw 'Network recovery consumed task launch budget.' } + } else { + $output = & (Join-Path $PSHOME 'pwsh.exe') -NoProfile -File (Join-Path $PSScriptRoot '../scripts/run-windows-runner-network-process.ps1') -RunnerRoot $runner -ConfigRoot $base -NetworkRoot $network -ReadinessScript (Join-Path $runner 'cold-boot-smoke.ps1') -StartScript (Join-Path $runner 'dsh-task/start-runner.ps1') @options 2>&1 | Out-String + } + if (($LASTEXITCODE -eq 0) -ne ($case -in @('ready','stale','reused','check-only','recovery'))) { throw "Unexpected exit: ${case}: $output" } + if ((Test-Path (Join-Path $runner 'dsh-task/listened')) -ne ($case -in @('ready','stale','reused','runner-failure','recovery','task-route'))) { throw "Listener admission failed: $case" } + if ($case -in @('recovery','task-route')) { + foreach ($expected in @('RUNNER-STDOUT','RUNNER-STDERR','RUNNER-SURVIVED-RECOVERY','Network process exited','Network process restarted (attempt 2)')) { + if (-not $output.Contains($expected)) { throw "Missing recovery evidence: $expected" } + } + } + if ($case -eq 'stale' -and -not $stale.HasExited) { throw 'Owned stale provider was not reaped' } + if ($case -eq 'reused' -and $stale.HasExited) { throw 'A reused PID was killed' } + if ($output.Contains('PRIVATE-OUTPUT')) { throw 'Provider output leaked' } + if (Test-Path (Join-Path $network 'child-pid')) { + $childId = [int](Get-Content (Join-Path $network 'child-pid')) + if (Get-Process -Id $childId -ErrorAction SilentlyContinue) { throw 'Child leaked' } + } + if ($case -ne 'bad-record' -and (Test-Path (Join-Path $network 'process.json'))) { throw 'Ownership record leaked' } + if ($case -eq 'bad-record' -and -not (Test-Path (Join-Path $network 'process.json'))) { throw 'Invalid identity evidence was removed' } + if (-not $held) { + $released = [IO.File]::Open((Join-Path $network 'supervisor.lock'),'OpenOrCreate','ReadWrite','None') + $released.Dispose() + } + Write-Output "PASS: $case" + } finally { + if ($stale) { + if (-not $stale.HasExited) { $stale.Kill(); $stale.WaitForExit() } + $stale.Dispose() + } + if ($held) { $held.Dispose() } + } + } +} finally { + if ([IO.Path]::GetDirectoryName($root).TrimEnd('\') -ne [IO.Path]::GetTempPath().TrimEnd('\')) { throw 'Unsafe cleanup path' } + Remove-Item -LiteralPath $root -Recurse -Force +} diff --git a/apps/desktop/tests/windows-runner-network.ps1 b/apps/desktop/tests/windows-runner-network.ps1 new file mode 100644 index 0000000000..bb9e4fe94e --- /dev/null +++ b/apps/desktop/tests/windows-runner-network.ps1 @@ -0,0 +1,117 @@ +# Isolated proxy fixture; no external requests, runner registration or signing hardware. +param([switch]$CheckStartup) +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot '../scripts/windows-runner-network.ps1') +$root = Join-Path ([IO.Path]::GetTempPath()) ('dsh-network-' + [guid]::NewGuid().ToString('N')) +$null = New-Item -ItemType Directory -Path $root +$null = New-Item -ItemType Directory -Path (Join-Path $root 'tools') +$pwshLink = Join-Path $root 'tools/pwsh-7.6.6' +if ($CheckStartup) { $null = New-Item -ItemType Junction -Path $pwshLink -Target $PSHOME } +$server = $null +$saved = @{} +foreach ($name in @('HTTP_PROXY','HTTPS_PROXY','NO_PROXY','NODE_USE_ENV_PROXY')) { $saved[$name] = [Environment]::GetEnvironmentVariable($name,'Process') } +try { + if ($null -ne (Read-RunnerNetwork $root)) { throw 'Absent configuration changed behavior.' } + $fixture = Join-Path $root 'proxy.cjs' + @' +const http = require('node:http'); +const fs = require('node:fs'); +const path = require('node:path'); +const root = process.argv[2]; +const server = http.createServer((req, res) => { + fs.appendFileSync(path.join(root, 'requests'), req.method + ' ' + req.url + '\n'); + if (req.url.includes('/hang')) return; + res.writeHead(req.url.includes('/fail') ? 503 : 200); + res.end(); +}); +server.on('connect', (req, socket) => { + socket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + server.emit('connection', socket); +}); +server.listen(0, '127.0.0.1', () => fs.writeFileSync(path.join(root, 'port'), String(server.address().port))); +'@ | Set-Content -LiteralPath $fixture + $info = [Diagnostics.ProcessStartInfo]::new((Get-Command node).Source) + $info.UseShellExecute = $false + $info.CreateNoWindow = $true + $info.ArgumentList.Add($fixture) + $info.ArgumentList.Add($root) + $server = [Diagnostics.Process]::Start($info) + $deadline = [DateTime]::UtcNow.AddSeconds(30) + while (-not (Test-Path (Join-Path $root 'port'))) { + if ($server.HasExited -or [DateTime]::UtcNow -gt $deadline) { throw 'Proxy fixture did not become ready.' } + Start-Sleep -Milliseconds 50 + } + $port = Get-Content (Join-Path $root 'port') -Raw + $config = @{proxyUrl="http://127.0.0.1:$port";noProxy='';serviceName='';probeUrl='http://runner-probe.invalid/ready';attempts=2;timeoutSeconds=2;retrySeconds=1} + function Write-Config { $config | ConvertTo-Json | Set-Content (Join-Path $root 'runner-network.json') } + Write-Config + $parsed = Read-RunnerNetwork $root + Set-RunnerNetwork $parsed + Wait-RunnerNetwork $parsed + if ($env:NODE_USE_ENV_PROXY -ne '1' -or $env:HTTP_PROXY -ne $config.proxyUrl -or $env:HTTPS_PROXY -ne $config.proxyUrl) { throw 'Proxy environment was not applied.' } + # Full startup requires the operator's installed packaging toolchain and persistent PATH. + if ($CheckStartup) { + $output = & (Join-Path $PSHOME 'pwsh.exe') -NoProfile -File (Join-Path $PSScriptRoot '../scripts/start-windows-packaging-runner.ps1') -RunnerRoot $root -ConfigRoot $root -CheckOnly 2>&1 | Out-String + if ($LASTEXITCODE -ne 0 -or -not $output.Contains('Runner network dependency ready')) { throw "Startup integration failed: $output" } + if ((Get-Content (Join-Path $root 'requests')).Count -ne 2) { throw 'Startup did not reach the configured proxy.' } + Clear-Content (Join-Path $root 'requests') + Wait-RunnerNetwork $parsed + } + $config.probeUrl = 'http://runner-probe.invalid/fail' + Write-Config + $failed = $false + try { Wait-RunnerNetwork (Read-RunnerNetwork $root) } catch { $failed = $_.Exception.Message.StartsWith('Runner network dependency unavailable;') } + if (-not $failed -or (Get-Content (Join-Path $root 'requests')).Count -ne 3) { throw 'Failed readiness did not stop at its budget.' } + $config.serviceName = 'fixture-service' + $config.probeUrl = 'http://runner-probe.invalid/ready' + $script:serviceChecks = 0 + function Get-Service { + param($Name) + $script:serviceChecks++ + [pscustomobject]@{ Status = if ($script:serviceChecks -eq 1) { 'Stopped' } else { 'Running' } } + } + try { + Wait-RunnerNetwork $config + if ($script:serviceChecks -ne 2 -or (Get-Content (Join-Path $root 'requests')).Count -ne 4) { throw 'Service readiness did not contain probes.' } + function Get-Service { throw 'fake-secret' } + $failure = $null + try { Wait-RunnerNetwork $config } catch { $failure = $_ } + if (-not $failure -or $failure.ToString().Contains('fake-secret') -or (Get-Content (Join-Path $root 'requests')).Count -ne 4) { throw 'Missing service was not contained.' } + } finally { Remove-Item Function:Get-Service } + $config.serviceName = '' + $config.proxyUrl = 'http://127.0.0.1:1' + $config.noProxy = '127.0.0.1' + $config.probeUrl = "http://127.0.0.1:$port/ready" + Set-RunnerNetwork $config + Wait-RunnerNetwork $config + if ((Get-Content (Join-Path $root 'requests')).Count -ne 5) { throw 'NO_PROXY did not bypass the unavailable proxy.' } + $config.probeUrl = "http://127.0.0.1:$port/hang" + $config.attempts = 1 + $config.timeoutSeconds = 1 + $failure = $null + try { Wait-RunnerNetwork $config } catch { $failure = $_ } + if (-not $failure -or (Get-Content (Join-Path $root 'requests')).Count -ne 6) { throw 'Unresponsive endpoint did not terminate its probe.' } + foreach ($invalid in @('extra','credential','budget','type','wildcard','malformed')) { + $bad = $config.Clone() + switch ($invalid) { + 'extra' { $bad.unknown = 'invalid' } + 'credential' { $bad.proxyUrl = 'http://fake-secret:fake-secret@localhost:1' } + 'budget' { $bad.attempts = 0 } + 'type' { $bad.retrySeconds = '2' } + 'wildcard' { $bad.serviceName = '*' } + } + $json = if ($invalid -eq 'malformed') { '{fake-secret' } else { $bad | ConvertTo-Json } + $json | Set-Content (Join-Path $root 'runner-network.json') + $failure = $null + try { $null = Read-RunnerNetwork $root } catch { $failure = $_ } + if (-not $failure -or $failure.ToString().Contains('fake-secret')) { throw 'Invalid configuration accepted or disclosed.' } + } + Write-Output "PASS: absent config, proxy routing, Node proxy opt-in, service readiness, bounded failure and redacted invalid config; startup integration=$CheckStartup." +} finally { + foreach ($name in $saved.Keys) { [Environment]::SetEnvironmentVariable($name,$saved[$name],'Process') } + if ($server) { if (-not $server.HasExited) { $server.Kill($true); $server.WaitForExit() }; $server.Dispose() } + if ($CheckStartup) { [IO.Directory]::Delete($pwshLink) } + if ([IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($root)).TrimEnd('\') -ne [IO.Path]::GetTempPath().TrimEnd('\')) { throw 'Fixture cleanup escaped the temporary directory.' } + Remove-Item -LiteralPath $root -Recurse -Force +} +exit 0 diff --git a/apps/desktop/tests/windows-runner-task.ps1 b/apps/desktop/tests/windows-runner-task.ps1 index da9a8ff8ac..d9e3009ef1 100644 --- a/apps/desktop/tests/windows-runner-task.ps1 +++ b/apps/desktop/tests/windows-runner-task.ps1 @@ -7,7 +7,7 @@ $source = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../scripts')) $runner = Join-Path $root 'runner with spaces' $null = New-Item -ItemType Directory -Path $runner Set-Content -LiteralPath (Join-Path $runner '.runner') -Value '{}' -foreach ($file in @('windows-runner-task.ps1','run-windows-runner-task.ps1','windows-runner-launcher.cs')) { Copy-Item -LiteralPath (Join-Path $source $file) -Destination $root } +foreach ($file in @('windows-runner-task.ps1','run-windows-runner-task.ps1','windows-runner-launcher.cs','windows-runner-network.ps1','run-windows-runner-network-process.ps1')) { Copy-Item -LiteralPath (Join-Path $source $file) -Destination $root } $manager = Join-Path $root 'windows-runner-task.ps1' @' param([string]$RunnerRoot, [string]$ConfigRoot, [switch]$CheckOnly) diff --git a/apps/desktop/windows-signing/README.i18n.yaml b/apps/desktop/windows-signing/README.i18n.yaml index f64f0ef778..0586d6b00d 100644 --- a/apps/desktop/windows-signing/README.i18n.yaml +++ b/apps/desktop/windows-signing/README.i18n.yaml @@ -21,8 +21,11 @@ en: bb889e9514678d1c zh: 48cbec1e907d6a2b /windows-signing-and-runner-operations/machine-setup-and-runner-startup/supervised-startup: - en: d8e2ed7bde76ed7e - zh: 475917ecbd158b48 + en: bff3bc48ef5d6545 + zh: 8ba75ab95a506b80 +/windows-signing-and-runner-operations/machine-setup-and-runner-startup/optional-network-dependency: + en: 60f34f8637b3c252 + zh: 805435964ab20263 /windows-signing-and-runner-operations/signing-configuration: en: 4b5548818375eb9a zh: 8c975adbb1dca9c8 diff --git a/apps/desktop/windows-signing/README.md b/apps/desktop/windows-signing/README.md index ec8312eddc..69d1933577 100644 --- a/apps/desktop/windows-signing/README.md +++ b/apps/desktop/windows-signing/README.md @@ -46,6 +46,16 @@ Before maintenance, pause new workflow dispatch and wait for the runner to becom Operators can run `pwsh -NoProfile -File apps/desktop/tests/windows-runner-task.ps1` on Windows to exercise installation, console-free initial launch and automatic recovery, duplicate-start prevention, stop, child-process teardown, containment of startup/logging failures and uninstall against an isolated fake runner. This opt-in test creates a temporary task and retains diagnostic files; it does not contact GitHub or access signing hardware. `pwsh -NoProfile -File apps/desktop/tests/windows-runner-launcher.ps1` checks the GUI executable, child console absence, exit status, diagnostic output and bounded draining with an inherited writer without Task Scheduler; Windows CI runs it. Real-machine acceptance additionally checks an idle runner coming online without a visible Terminal window, an Actions job being assigned, and login after an operator-approved reboot. Signed packaging and logout/reboot acceptance require separate operator verification. +### Optional network dependency + +Without `runner-network.json` in `ConfigRoot`, startup retains its scrubbed direct-network behavior. To configure a network dependency, create that operator-owned JSON file outside the checkout with exactly `proxyUrl`, `noProxy`, `serviceName`, `probeUrl`, `attempts`, `timeoutSeconds` and `retrySeconds`. The first four fields are strings; the three budgets are integers from 1 through 60. Use an empty `proxyUrl` for direct access and an empty `serviceName` when no Windows service owns the dependency. URLs must use HTTP(S) without credentials, queries or fragments; the proxy URL cannot include a path. Startup applies `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY` and `NODE_USE_ENV_PROXY` after environment scrubbing, then requires a successful Node HEAD probe before admitting the runner. Service readiness checks never start or stop a Windows service. + +For a dedicated child process, add `-NetworkRoot ` to task installation. That directory must contain `launch.json` with exactly an absolute `executable` filename, an `arguments` string array and an integer `restartDelaySeconds` from 1 through 3600. This mode requires `runner-network.json`; set its `serviceName` to empty. Give the child its own data directory, listener bindings and executable identity, distinct from applications managed by desktop software; separate ports alone do not prevent another manager from terminating a process by executable name. The scripts do not alter desktop network settings. Keep actual provider names, paths, addresses, ports and arguments in machine configuration, never in repository files or shared logs. Restrict access to both configuration directories to the runner operator. + +Installation starts the dependency for readiness checking and stops it before registering the task. While the runner lives, the supervisor restarts only an exited owned network process after the configured delay, including repeated launch failures; it does not restart the runner or retry jobs or signing. Exit, restart and launch-failure messages appear in `run-*.log`; provider output is discarded. Restart messages establish process creation, not network availability: confirm reconnection in runner logs. A live but unresponsive dependency requires operator diagnosis. The supervisor exclusively locks `NetworkRoot`, verifies PID, start time and executable before reclaiming a stale child, and stops its child when the runner exits. Unrecoverable supervision errors leave the active runner alive until it exits and report that recovery has stopped. Preserve `NetworkRoot` when reinstalling; pulling changes alone does not replace installed helpers. + +Windows CI runs `pwsh -NoProfile -File apps/desktop/tests/windows-runner-network.ps1` and `pwsh -NoProfile -File apps/desktop/tests/windows-runner-network-process.ps1` with isolated local fixtures. On a configured packaging machine, add `-CheckStartup` to the first command to verify network configuration after the actual startup environment scrub. These tests use no signing hardware or external network endpoints. + ## Signing configuration diff --git a/apps/desktop/windows-signing/README.zh.md b/apps/desktop/windows-signing/README.zh.md index 3b957c5f57..53634890d9 100644 --- a/apps/desktop/windows-signing/README.zh.md +++ b/apps/desktop/windows-signing/README.zh.md @@ -46,6 +46,16 @@ description: "配置和维护 Windows 打包 runner、签名凭据、EV Token 操作者可在 Windows 上运行 `pwsh -NoProfile -File apps/desktop/tests/windows-runner-task.ps1`,使用隔离的假 runner 验证安装、无控制台的首次启动与自动恢复、防止重复启动、停止、子进程退出、启动/日志失败时的禁用处理和卸载。此显式运行的测试创建临时任务并保留诊断文件,不连接 GitHub 或访问签名硬件。`pwsh -NoProfile -File apps/desktop/tests/windows-runner-launcher.ps1` 无需计划任务,检查 GUI 可执行文件、子进程无控制台、退出状态、诊断输出以及存在继承管道写端时的有界排空;Windows CI 会执行该测试。真实机器验收还需检查空闲 runner 上线且不出现 Terminal 窗口、Actions job 分配,以及经操作者批准的重启后登录。签名打包和注销/重启验收需要操作者单独验证。 +### 可选网络依赖 + +`ConfigRoot` 中没有 `runner-network.json` 时,启动仍使用清理继承环境后的直连行为。要配置网络依赖,请在 checkout 外创建操作者管理的该 JSON 文件,且只包含 `proxyUrl`、`noProxy`、`serviceName`、`probeUrl`、`attempts`、`timeoutSeconds` 和 `retrySeconds`。前四个字段为字符串,后三个预算为 1 至 60 的整数。直连时将 `proxyUrl` 留空,没有 Windows 服务管理依赖时将 `serviceName` 留空。URL 必须使用 HTTP(S),不能包含凭据、查询参数或片段;代理 URL 不能包含路径。启动脚本在清理环境后设置 `HTTP_PROXY`、`HTTPS_PROXY`、`NO_PROXY` 和 `NODE_USE_ENV_PROXY`,并要求 Node HEAD 探测成功才允许启动 runner。服务就绪检查不会启动或停止 Windows 服务。 + +若使用专用子进程,在安装任务时添加 `-NetworkRoot `。该目录必须包含 `launch.json`,且只包含绝对路径的 `executable` 文件名、字符串数组 `arguments`,以及 1 至 3600 的整数 `restartDelaySeconds`。此模式要求存在 `runner-network.json`,其中 `serviceName` 应留空。为子进程分配独立的数据目录、监听绑定和可执行文件标识,与桌面软件管理的应用分离;仅使用不同端口无法防止其他管理器按可执行文件名终止进程。脚本不会修改桌面网络设置。实际服务商名称、路径、地址、端口和参数只保存在机器配置中,不得写入仓库文件或共享日志。将两个配置目录的访问权限限制为 runner 操作者。 + +安装时会启动依赖进行就绪检查,并在注册任务前停止它。runner 存活期间,守卫仅在自己拥有的网络进程退出后,按配置间隔重新启动它;启动连续失败时仍会重试,但不会重启 runner、重跑任务或重试签名。退出、重启和启动失败消息写入 `run-*.log`,依赖进程的输出会被丢弃。重启消息只表明进程已创建,不代表网络可用;请在 runner 日志中确认重新连接。进程仍存活但无响应时需要操作者排查。守卫独占锁定 `NetworkRoot`,回收残留子进程前核对 PID、启动时间和可执行文件,并在 runner 退出时停止子进程。不可恢复的守卫错误会保留活动 runner 直到其退出,并报告恢复已停止。重新安装时保留 `NetworkRoot`;仅拉取代码不会替换已安装的辅助脚本。 + +Windows CI 使用隔离的本地 fixture 运行 `pwsh -NoProfile -File apps/desktop/tests/windows-runner-network.ps1` 和 `pwsh -NoProfile -File apps/desktop/tests/windows-runner-network-process.ps1`。在已配置的打包机器上,给第一条命令添加 `-CheckStartup`,可验证实际启动环境清理后的网络配置。这些测试不会使用签名硬件或外部网络端点。 + ## 签名配置 From 0c23ad233da2feac5ca252f01b382a6b37320628 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:11:07 +0800 Subject: [PATCH 34/46] refactor(session-controller): remove unused inspection facade --- packages/api/session-controller/src/agent.ts | 35 ------------ .../api/session-controller/src/commands.ts | 29 +++------- packages/api/session-controller/src/index.ts | 25 --------- .../tests/agent.host.spec.ts | 54 ------------------- .../tests/controller.host.spec.ts | 12 ----- 5 files changed, 8 insertions(+), 147 deletions(-) diff --git a/packages/api/session-controller/src/agent.ts b/packages/api/session-controller/src/agent.ts index 4467fefc0f..d16e041773 100644 --- a/packages/api/session-controller/src/agent.ts +++ b/packages/api/session-controller/src/agent.ts @@ -9,7 +9,6 @@ import { installModelSelection } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-preset-registry' import { brandString } from '@deepseek-ai/dsh-brand' import type { Session, SessionId } from '@deepseek-ai/dsh-session' -import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import type {} from '@deepseek-ai/dsh-typert-registry' @@ -123,40 +122,6 @@ export function apiSessionSubagentOwnershipError(sessionId: SessionId): ApiSessi ) } -/** - * Inspect one cold Session without repairing, resuming, or publishing it. - * @param ctx - Host context carrying Session persistence. - * @param sessionId - durable Session identity. - * @param signal - optional cancellation for persistence reads. - * @returns the persisted header and complete event prefix. - */ -export async function inspectApiSession( - ctx: Context, - sessionId: SessionId, - signal?: AbortSignal, -): Promise { - try { - using observation = await ctx.sessionQuery.observeSession(sessionId, { - ...(signal === undefined ? {} : { signal }), - projectionMode: 'none', - }) - if (observation.header.cwd === undefined) { - throw new ApiSessionNotFound(`session "${sessionId}" not found`) - } - return { - meta: observation.header, - inheritedEventCount: observation.inheritedEventCount, - events: [...observation.events], - } - } catch (error: unknown) { - if (error instanceof SessionQueryError - && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') { - throw new ApiSessionNotFound(`session "${sessionId}" not found`) - } - throw error - } -} - /** Owns every operation that may create, resume, or configure a Web Agent. */ export class ApiSessionAgentController { private readonly resumes = new Map>() diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index fc7240b83f..d7e30384fe 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -31,7 +31,6 @@ import { ApiSessionSubagentOwnership, apiSessionSubagentOwnershipError, hasApiSessionSubagentOwner, - inspectApiSession, type ApiSessionCreated, } from './agent.ts' import type { @@ -54,12 +53,6 @@ import type { SessionRequestId, } from './types.ts' -interface SessionReadState { - readonly id: SessionId - readonly header: SessionHeader - readonly events: readonly SessionEvent[] -} - type PromptContentCandidate = | SessionPromptRequest['content'][number] | Extract['content'][number] @@ -371,11 +364,16 @@ export class SessionCommandController { * @returns the durable attachment reference and base64-encoded bytes. */ async attachment(request: SessionAttachmentRequest): Promise { - let source: SessionReadState + let ref: ImageAttachmentRef | undefined try { - source = await this.readSessionState(request.sessionId) + using observation = await this.ctx.sessionQuery.observeSession(request.sessionId, { projectionMode: 'none' }) + if (observation.source === 'prepared' && observation.header.cwd === undefined) { + throw new ApiSessionNotFound(`session "${request.sessionId}" not found`) + } + ref = referencedImage(observation.events, String(request.attachmentId)) } catch (error) { - if (error instanceof ApiSessionNotFound) { + if (error instanceof ApiSessionNotFound + || (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND')) { throw new RemoteError('session/not-found', error.message, { sessionId: request.sessionId }) } throw new RemoteError( @@ -384,7 +382,6 @@ export class SessionCommandController { {}, ) } - const ref = referencedImage(source.events, String(request.attachmentId)) if (ref === undefined) { throw new RemoteError( 'session/attachment-invalid', @@ -533,16 +530,6 @@ export class SessionCommandController { throw new RemoteError('gateway/internal', `failed to create session: ${String(error)}`, {}) } - private async readSessionState(sessionId: SessionId): Promise { - const attached = this.ctx.sessions.get(sessionId) - if (attached !== undefined) { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. - return { id: attached.id, header: attached.header, events: attached.snapshotEvents() } - } - const inspected = await inspectApiSession(this.ctx, sessionId) - return { id: inspected.meta.id, header: inspected.meta, events: inspected.events } - } - private async forkWorkspace(source: SessionHeader): Promise { const workspaces = this.ctx.workspaceRegistry.list() const direct = workspaces.find(workspace => workspace.sessionIds.includes(source.id)) diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 5eeb7ae03d..85770b8e21 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -10,7 +10,6 @@ import { errorChain, ReasoningEffortId, type MessageSource } from '@deepseek-ai/ import type {} from '@deepseek-ai/dsh-client-file-upload' import { canOpenNativePath, nativeFileManager, nativeFileApplications, openNativeFileApplication, openNativeAssociatedPath, revealNativePath } from '@deepseek-ai/dsh-native-command' import type { SessionId } from '@deepseek-ai/dsh-session' -import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' import type {} from '@deepseek-ai/dsh-session-projection-cache' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' import { Remote, RemoteError, TypertRemoteService, principalOf } from '@deepseek-ai/dsh-typert-protocol' @@ -19,7 +18,6 @@ import { assertNever } from '@deepseek-ai/dsh-util-values' import type {} from '@deepseek-ai/dsh-api-remote-access' import { ApiSessionAgentController, - inspectApiSession, type ApiSessionAgentResult, } from './agent.ts' import { SessionCommandController, hasPromptContent } from './commands.ts' @@ -240,28 +238,6 @@ export class SessionController extends TypertRemoteService { return this.agents.resolveAgent(sessionId) } - /** - * Inspect one attached or persisted Session without activating its Agent. - * @param sessionId - durable Session identity. - * @param signal - optional caller cancellation for persistence reads. - * @returns the current attached state or persisted header and event prefix. - */ - inspect( - sessionId: SessionId, - signal?: AbortSignal, - ): Promise { - const attached = this.ctx.sessions.get(sessionId) - if (attached !== undefined) { - return Promise.resolve({ - meta: attached.header, - inheritedEventCount: attached.inheritedEventCount, - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. - events: attached.snapshotEvents(), - }) - } - return inspectApiSession(this.ctx, sessionId, signal) - } - /** * Read all visible Session rows without resuming an Agent. * @param _request - reserved empty list request. @@ -614,5 +590,4 @@ export class SessionController extends TypertRemoteService { } -export { buildModelCatalog } export default SessionController diff --git a/packages/api/session-controller/tests/agent.host.spec.ts b/packages/api/session-controller/tests/agent.host.spec.ts index 76380d69fb..45a6d7e29a 100644 --- a/packages/api/session-controller/tests/agent.host.spec.ts +++ b/packages/api/session-controller/tests/agent.host.spec.ts @@ -16,7 +16,6 @@ import { ApiSessionAgentController, ApiSessionCwdConflict, ApiSessionNotFound, - inspectApiSession, } from '../src/agent.ts' import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts' @@ -84,60 +83,7 @@ describe('ApiSession identity failures', () => { .toContain('belongs to "/existing"') }) - it('maps absent and cwd-less point observations to not found', async () => { - const ctx = new Context() - roots.push(ctx) - await ctx.plugin(SessionStore) - installSessionReadTestServices(ctx) - await expect(inspectApiSession(ctx, SessionId('missing'))) - .rejects.toBeInstanceOf(ApiSessionNotFound) - const inspect = vi.fn(() => Promise.resolve(undefined)) - const stat = vi.fn(() => Promise.resolve(undefined)) - const disposeMissing = providePersistence(ctx, { - list: () => Promise.resolve([]), - stat, - inspect, - }) - await expect(inspectApiSession(ctx, SessionId('missing'))).rejects.toBeInstanceOf(ApiSessionNotFound) - // Absence is decided by the stat preflight; the log itself is never opened. - expect(stat).toHaveBeenCalledOnce() - expect(inspect).not.toHaveBeenCalled() - disposeMissing() - - const listed = header('cwd-less-catalog', null) - const disposeListed = providePersistence(ctx, { - list: () => Promise.resolve([listed]), - inspect: () => Promise.resolve({ meta: listed, events: [] }), - }) - await expect(inspectApiSession(ctx, listed.id)).rejects.toBeInstanceOf(ApiSessionNotFound) - disposeListed() - - const catalog = header('cwd-less-inspect') - const inspected = header('cwd-less-inspect', null) - providePersistence(ctx, { - list: () => Promise.resolve([catalog]), - inspect: () => Promise.resolve({ meta: inspected, events: [] }), - }) - await expect(inspectApiSession(ctx, catalog.id)).rejects.toBeInstanceOf(ApiSessionNotFound) - }) - - it('forwards an explicit inspection signal', async () => { - const ctx = new Context() - roots.push(ctx) - await ctx.plugin(SessionStore) - installSessionReadTestServices(ctx) - const meta = header('signalled-inspection') - const inspect = vi.fn(() => Promise.resolve({ meta, inheritedEventCount: SessionLogOffset(0), events: [] })) - providePersistence(ctx, { - list: () => Promise.resolve([meta]), - inspect, - }) - const signal = new AbortController().signal - - await expect(inspectApiSession(ctx, meta.id, signal)).resolves.toEqual({ meta, inheritedEventCount: SessionLogOffset(0), events: [] }) - expect(inspect).toHaveBeenCalledWith(meta.id, signal) - }) }) describe('ApiSession Agent lookup and recovery', () => { diff --git a/packages/api/session-controller/tests/controller.host.spec.ts b/packages/api/session-controller/tests/controller.host.spec.ts index 789caf78d5..15c438764a 100644 --- a/packages/api/session-controller/tests/controller.host.spec.ts +++ b/packages/api/session-controller/tests/controller.host.spec.ts @@ -63,12 +63,6 @@ describe('SessionController facade', () => { ctx.on('api-session/error', failure) ctx.on('api-session/activity', activity) - await expect(controller.inspect(sessionId)).resolves.toEqual({ - meta: header, - inheritedEventCount: SessionLogOffset(0), - events, - }) - expect(inspect).toHaveBeenCalledOnce() const session = ctx.sessions.create(sessionId, { meta: header }) const agent = { @@ -91,12 +85,6 @@ describe('SessionController facade', () => { await expect(resolveUploadAgent(sessionId)).rejects.toBe(activationError) await expect(controller.resolveAgent(sessionId)).resolves.toEqual({ agent }) - await expect(controller.inspect(sessionId)).resolves.toEqual({ - meta: header, - inheritedEventCount: SessionLogOffset(0), - events, - }) - expect(inspect).toHaveBeenCalledOnce() ctx.emit('agent/status', { agent, status: 'running' }) ctx.emit('agent/error', { agent, turn: 1, step: 0, error: new Error('fixture failure') }) session.append('user/message', createUserMessage({ From fea3bcdbf43a90a0f0dd5380bee34a747fe4bf07 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:05:38 +0800 Subject: [PATCH 35/46] feat(typert): support default resource resolvers --- ...-session-history-stream-and-snapshot.zh.md | 110 ++++++++++++++++++ packages/api/gateway/src/access.ts | 31 +++-- .../gateway/tests/gateway-access.host.spec.ts | 29 +++++ packages/api/session-controller/src/index.ts | 19 +-- .../tests/remote-access.host.spec.ts | 7 +- packages/typert/protocol/src/types.ts | 17 +-- packages/typert/registry/src/service.ts | 6 +- 7 files changed, 181 insertions(+), 38 deletions(-) create mode 100644 .agents/notes/proposed/architecture/2026-10-21-session-history-stream-and-snapshot.zh.md diff --git a/.agents/notes/proposed/architecture/2026-10-21-session-history-stream-and-snapshot.zh.md b/.agents/notes/proposed/architecture/2026-10-21-session-history-stream-and-snapshot.zh.md new file mode 100644 index 0000000000..f6565f9dec --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-10-21-session-history-stream-and-snapshot.zh.md @@ -0,0 +1,110 @@ +# Agent Note: Session 历史快照、纯监听与 Web Follow 聚合 + +Status: proposed + +## Problem + +历史的一次读取、实时事件订阅和 Web 开场聚合需要不同的生命周期。若纯监听也读取 Observation 来计算起点,订阅就引入不必要的历史读取;若聚合层另建历史读取模块,又会让标准接口与 Web 各自拥有一条读取路径。需要明确资源持有和接续顺序,使历史快照与实时事件能组合而不重复读取、不遗漏开场期间的事件。 + +## Proposal + +在 session-controller 包内保留并行的 `NewSessionController`(`newSession`)与 `NewWebController`(`newWeb`)。旧 `SessionController`、旧客户端与旧 API 不变;新接口仅交付 History Record,不包含 header、projection、assistant stream 或 activate/resume。 + +### 职责与接口 + +| 所有者 | 职责 | 不承担的工作 | +|---|---|---| +| `NewSessionController.history` | 取得 Observation,执行 `paginate`、`pageRecords`,返回 `{ cursor, records, hasMore }`,随后释放租约 | 不监听事件、不激活 | +| `NewSessionController.observeHistory` | 创建 Listener,发出无 cursor 的 `ready`,转发 `listener.events()` | 不读取历史、不取得 Observation | +| `NewWebController.follow` | 先创建 Listener,再在 Host 内直接调用 `ctx.newSessionController.history`,发 snapshot 后转发 `eventsAfter(cursor)` | 不另建 `historyRead` 模块、不经 RPC 调用标准方法 | +| `HistoryListener` | 注册 `session/event` 和 `session/created` 适配,缓冲、去重、检查连续性,以 `entryFor` 转为事件帧并输出,关闭时释放监听 | 不取得或持有 Observation、不补读快照、不激活 | + +`history` 接收 `address`、可选的 `throughSeq`、`beforeSeq` 与分页预算。不传 `throughSeq` 时,返回的 cursor 是本次 Observation 的末尾;传入时必须是该 Observation 可读取的有效点位。`beforeSeq` 只选择更早的页面,不替代基准 cursor;返回 cursor 不等于旧页最后一条记录的位置。`follow` 只接收 address 和开场分页预算,不接受旧 `throughSeq` 或 `beforeSeq`,开场读取当前点位的最新页。 + +复用已有 [SessionObservation](../../../../packages/session-query/session-query/src/observation.ts) 定义及 [Observation 决策](../../implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md),不引入来自 #4107 的新概念。`acquireObservation` 仅表示现有 `sessionQuery.observeSession` 加地址校验与错误映射;租约只在 `history` 方法生成返回值期间持有,不转交给 Listener,也不绑定流的整个生命周期。 + +### 三个入口 + +以下是职责示意,不作为可编译声明。`resolveHistoryRequest` 表示入口的参数校验和显式默认值处理;请求取消在读取完成后、返回结果前再次检查。 + +```text +// NewSessionController +async history(request, signal) { + const spec = resolveHistoryRequest(request) + using observation = await acquireObservation(this.ctx, spec.address, signal) + signal.throwIfAborted() + const cursor = spec.throughSeq ?? observation.cursor + validateHistoryCut(cursor, observation.cursor) + const page = paginate( + observation.events, spec.beforeSeq, spec.maxMessages, cursor, spec.turnWindow, + ) + return { cursor, records: pageRecords(page.events), hasMore: page.hasMore } +} +``` + +```text +// NewSessionController +async *observeHistory(request, signal) { + using listener = new HistoryListener(this.ctx, request.address, signal) + yield { type: 'ready' } + yield* listener.events() +} +``` + +```text +// NewWebController +async *follow(request, signal) { + using listener = new HistoryListener(this.ctx, request.address, signal) + const opening = await this.ctx.newSessionController.history(request, signal) + yield { type: 'snapshot', ...opening } + yield* listener.eventsAfter(opening.cursor) +} +``` + +### Listener 与快照接续 + +Listener 构造完成时已同步挂好事件监听并开始缓冲。`events()` 和 `eventsAfter(C)` 是同一个缓冲的两种单消费者选择,一次 Listener 生命周期只能选择并消费其中一种;不增加 `align` 或第二次初始化。两者返回异步事件帧序列,生成器延迟到首次迭代才执行,不影响构造时已开始的监听与缓冲。 + +- `events()` 输出订阅生效后收集的事件,以首个可输出事件建立序列起点,之后去重并检查连续性;不承诺交付订阅前的历史。 +- `eventsAfter(C)` 丢弃 `seq ≤ C` 的事件,第一条必须是 `C + 1`,随后保持连续;尚未收到下一条时等待,遇到缺号则明确结束并报告错误,不静默跳过或补读。 +- `session/created` 适配负责把构造期未逐条广播的种子事件纳入同一输入。这不调用 Observation,也不补读持久化快照;实现必须覆盖生成器尚未开始消费时发生创建的情况,不能依赖提前执行生成器来避免丢失。 + +例如 Listener 首先收到 15,读取 History 期间已缓冲 15~20,而 History 返回 `cursor = 20`。Follow 先发覆盖到 20 的 snapshot,再由 Listener 丢弃缓冲中重复的 15~20,从 21 开始输出增量。丢弃的是监听缓冲中的重叠,不是快照里的记录;不把快照故意截回 14。快照按页交付,更早记录仍通过 History 分页读取。 + +### Ready、授权与释放 + +`ready` 只确认该流的监听已安装,不包含 cursor,不证明会话存在、子会话身份已经验证或 Agent 就绪。独立组合接口的调用方持续消费并缓冲流,收到 ready 后再请求 History,应用历史页后按返回 cursor 丢弃重复增量;这保证远端请求顺序不会把读取排在监听生效之前。 + +Remote 方法继续声明现有 `resources` 会话授权;Host 内部直接调用不代替对 `follow` 入口本身的授权。纯流如何在不调用 Observation 的情况下完成子会话身份验证,是实现前必须核实的安全项,本提案不指定替代校验机制;不能把 ready 当成授权证据,也不能在必要身份校验未完成时输出事件数据。 + +History 成功、失败或取消都释放已取得的 Observation。流结束、调用方取消、插件卸载、历史读取失败及事件处理错误都必须撤销监听、释放缓冲并唤醒等待者;部分监听注册失败也必须清理已注册部分。Listener 不因没有新事件而阻止取消完成。 + +### 与既有决策及后续范围的关系 + +[历史与 Remote 事件传输决策](../../implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md)继续拥有旧 API、传输生命周期与客户端恢复行为;[Observation 决策](../../implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md)继续拥有精确读取和 projection 状态。两篇仍有独立作用,本提案不替代或归档它们。 + +History-only `newWeb.follow` 不是旧完整 Web 协议的直接替换;旧客户端迁移不在本阶段。Projection 下一阶段先讨论独立接口,再讨论 Follow 多数据聚合与同游标问题;本提案不预设共享 Observation、projection revision 或其他对齐方案。 + +## Alternatives considered + +**先读历史,再开 tail 补读缺口。** 不采用;先安装监听就能覆盖读取期间的增量,不需要额外历史读取或任意旧游标续传能力。 + +**纯监听取得 Observation 并返回 ready.cursor。** 不采用;监听只需要确认订阅生效,历史点位由独立 History 返回。 + +**另建 historyRead 模块或让 Listener 持有 Observation。** 不采用;标准 History 方法已经拥有一次读取,Listener 仅按结果 cursor 消费缓冲,二者无需共享资源所有权。 + +## Acceptance criteria + +- History 一次调用取得一份 Observation,返回与该读取一致的 cursor、records、hasMore;覆盖显式点位、最新点位、空历史、非法点位及旧页分页。 +- 纯监听不调用 Observation 或激活路径;ready 不含 cursor,始终先于事件帧,事件去重并保持已建立起点之后的连续性。 +- Follow 的监听注册先于 History 调用,且仅调用一次标准 History;开场第一帧是 snapshot,重叠例子中第一条增量为 21,生成器延迟消费期间的事件不丢失。 +- 覆盖监听期间 Session 创建、种子事件与普通通知重复、缺号、取消、消费者结束、读取失败及插件卸载;断言租约、监听与等待者全部释放。 +- 实现前核实纯流子会话身份验证;拒绝错误父子关系或身份不符的地址,不能以 ready 或 child id 过滤替代授权。Host 集成测试覆盖真实资源授权与直接方法调用路径。 +- 新增无密钥快照用例固定 history-only 的开场与增量输出;旧 API 与旧客户端的协议、激活行为保持不变。测试结果与类型检查在实现阶段提供,本提案不代表现有示例代码已通过验证。 + +## Risks + +- 纯流不提供历史起点或存在性结论;调用方必须正确理解 ready,并在需要历史时按先订阅后读取的顺序组合。 +- 开场读取慢或消费者停止拉取会积累缓冲。实现前需核实项目既有背压与容量策略;不能通过静默丢事件维持连接,本提案不新增容量配置。 +- 子会话身份校验和 `session/created` 在消费开始前的适配必须完成验证,不能因为新入口更短而削弱数据访问控制或事件完整性。 +- 新旧接口并行保留有暂时维护成本;命名相近不表示新 Follow 已提供旧 Web 的全部数据或副作用。 diff --git a/packages/api/gateway/src/access.ts b/packages/api/gateway/src/access.ts index d85ebb3e65..b9c5ae42be 100644 --- a/packages/api/gateway/src/access.ts +++ b/packages/api/gateway/src/access.ts @@ -46,25 +46,24 @@ export function callResources( ): readonly RemoteAccessResource[] | undefined { // Each later source replaces an earlier one for its kind and keeps the kind's position. const sources = new Map ResourceIds>() - for (const { kind, field } of typert.resources.entries()) { - sources.set(kind, () => { - // A default field names exactly one resource id; only a reader may return several. - const id = defaultFieldId(descriptor, args, field) + const positionalArgs: readonly unknown[] = [ + ...descriptor.parameters.map(parameter => args[parameter.wire]), + ...descriptor.cancellation === undefined ? [] : [signal], + ] + for (const declaration of typert.resources.entries()) { + sources.set(declaration.kind, () => { + if ('resolve' in declaration) { + return read(() => declaration.resolve(...positionalArgs), []) + } + const id = defaultFieldId(descriptor, args, declaration.field) return Array.isArray(id) ? 'invalid' : id === undefined ? [] : [id] }) } for (const [kind, ids] of lookupResources(typert, descriptor, args)) { sources.set(kind, () => ids) } - let positional: readonly unknown[] | undefined for (const [kind, reader] of Object.entries(readers ?? {})) { - sources.set(kind, () => { - positional ??= [ - ...descriptor.parameters.map(parameter => args[parameter.wire]), - ...descriptor.cancellation === undefined ? [] : [signal], - ] - return read(reader, positional, []) - }) + sources.set(kind, () => read(() => reader(...positionalArgs), [])) } const resources: RemoteAccessResource[] = [] for (const [kind, ids] of sources) { @@ -91,7 +90,7 @@ export function eventResources( if (declared.length === 0) return GLOBAL const resources: RemoteAccessResource[] = [] for (const [kind, reader] of declared) { - if (!collect(resources, kind, read(reader, args, 'invalid'))) return undefined + if (!collect(resources, kind, read(() => reader(...args), 'invalid'))) return undefined } return resources } @@ -129,11 +128,11 @@ function lookupResources( return ids } -/** Run one reader with spread positional arguments: `undefined` yields `absent`, a list names several and must not be empty. */ -function read(reader: TypertResourceReader, args: readonly unknown[], absent: ResourceIds): ResourceIds { +/** Read resource identities: `undefined` yields `absent`, a list names several and must not be empty. */ +function read(reader: () => unknown, absent: ResourceIds): ResourceIds { let value: unknown try { - value = reader(...args) + value = reader() } catch { // Any reader error means it cannot name its resources, so they are invalid. return 'invalid' diff --git a/packages/api/gateway/tests/gateway-access.host.spec.ts b/packages/api/gateway/tests/gateway-access.host.spec.ts index 960405939f..374fcf0cfa 100644 --- a/packages/api/gateway/tests/gateway-access.host.spec.ts +++ b/packages/api/gateway/tests/gateway-access.host.spec.ts @@ -868,6 +868,35 @@ describe('resource resolution', () => { expect(callResources(typert, undefined, method(), {}, NEVER)).toEqual([GLOBAL]) }) + it('resolves default identities through the registered function and preserves explicit overrides', async () => { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(TypertRegistry) + const resolve = vi.fn((request?: string | { address?: { id: string } }) => { + return typeof request === 'string' ? request : request?.address?.id + }) + const dispose = ctx.typert.resources.register('test-shelf', { resolve }) + expect(callResources(ctx.typert, undefined, method('roomId'), { roomId: 'r1' }, NEVER)).toEqual([ROOM]) + expect(callResources(ctx.typert, undefined, method('request'), { request: { address: { id: 'r1' } } }, NEVER)).toEqual([ROOM]) + expect(callResources(ctx.typert, undefined, method('request'), { request: {} }, NEVER)).toEqual([GLOBAL]) + resolve.mockClear() + const args = { other: {}, later: { address: { id: 'r1' } } } + expect(callResources(ctx.typert, undefined, method('other', 'later'), args, NEVER)).toEqual([GLOBAL]) + expect(resolve).toHaveBeenCalledExactlyOnceWith(args.other, args.later) + resolve.mockClear() + expect(callResources(ctx.typert, undefined, { + ...method('renamed'), cancellation: { parameter: 'signal' }, + }, { renamed: { address: { id: 'r1' } } }, NEVER)).toEqual([ROOM]) + expect(resolve).toHaveBeenCalledExactlyOnceWith({ address: { id: 'r1' } }, NEVER) + expect(callResources(ctx.typert, { 'test-shelf': () => 'override' }, method('request'), { + request: { address: { id: 'r1' } }, + }, NEVER)).toEqual([shelf('override')]) + await dispose() + expect(ctx.typert.resources.entries()).toEqual([]) + ctx.typert.resources.register('test-shelf', { resolve: () => { throw new Error('cannot resolve') } }) + expect(callResources(ctx.typert, undefined, method('request'), { request: {} }, NEVER)).toBeUndefined() + }) + it('lets a reader replace the defaults for its kind, called with the positional wire arguments', async () => { const typert = await registry() const readers = { 'test-shelf': (ids: unknown) => ids } diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 85770b8e21..e6d0acb551 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -78,13 +78,14 @@ declare module '@deepseek-ai/cordis' { } /** - * Read the Session resource a history address names: the Session itself, or - * the child Session whose transcript a subagent address reads. - * @param request - history request carrying the durable address. - * @returns the Session whose access the read requires. + * Extract the Session resource from an ID or a history address. + * @param request - first wire argument carrying a Session ID or address. + * @returns the target Session identity when present. */ -function addressedSession(request: { readonly address: SessionAddress }): SessionId { - return request.address.kind === 'session' ? request.address.sessionId : request.address.childSessionId +function resolveSessionResource(request?: { readonly sessionId?: SessionId; readonly address?: SessionAddress }): SessionId | undefined { + const address = request?.address + return request?.sessionId ?? (address === undefined ? undefined + : address.kind === 'session' ? address.sessionId : address.childSessionId) } /** Session Controller deployment policy. */ @@ -165,7 +166,7 @@ export class SessionController extends TypertRemoteService { protected async [Service.init](): Promise { const ctx = this.ctx - ctx.typert.resources.register('session', { field: 'sessionId' }) + ctx.typert.resources.register('session', { resolve: resolveSessionResource }) this.agents = new ApiSessionAgentController(ctx) this.agents.initialize() this.commands = new SessionCommandController(ctx, this.agents, process.cwd()) @@ -493,7 +494,7 @@ export class SessionController extends TypertRemoteService { * @param signal - cancellation for persistence reads. * @returns one chronological page. */ - @Remote({ name: 'page', resources: { session: addressedSession } }) + @Remote('page') page(request: SessionPageRequest, signal: AbortSignal): Promise { return this.history.page(request, signal) } @@ -505,7 +506,7 @@ export class SessionController extends TypertRemoteService { * @returns a complete opening snapshot followed by gap-free durable event * frames and optional cursorless assistant-stream frames. */ - @Remote({ mode: 'stream', resources: { session: addressedSession } }) + @Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable { return this.history.follow(request, signal) } diff --git a/packages/api/session-controller/tests/remote-access.host.spec.ts b/packages/api/session-controller/tests/remote-access.host.spec.ts index 42b78e5190..824eb5f702 100644 --- a/packages/api/session-controller/tests/remote-access.host.spec.ts +++ b/packages/api/session-controller/tests/remote-access.host.spec.ts @@ -107,15 +107,18 @@ function message(text: string) { } describe('Session Controller access', () => { - it('owns the Session resource kind and its default sessionId field', async () => { + it('owns the Session resource kind and its default resolver', async () => { const { ctx } = await harness() expect(() => ctx.typert.resources.register('session', { field: 'sessionId' })).toThrow(/already registered/) }) it('reads the Session resource of page and follow requests from the addressed Session', async () => { - const { controller } = await harness() + const { ctx, controller } = await harness() + const resource = ctx.typert.resources.get('session') + if (resource === undefined || !('resolve' in resource)) throw new Error('Session resource resolver is missing') for (const method of ['page', 'follow']) { const reader = remoteMethods(controller).find(marker => marker.method === method)?.resources?.['session'] + ?? resource.resolve expect(reader?.({ address: { kind: 'session', sessionId: SHARED } })).toBe(SHARED) // A subagent address reads the child transcript; whether the parent's access covers it is the policy's rule. expect(reader?.({ address: { kind: 'subagent', parentSessionId: SHARED, childSessionId: PRIVATE } })).toBe(PRIVATE) diff --git a/packages/typert/protocol/src/types.ts b/packages/typert/protocol/src/types.ts index 650b2b3556..6b1d658b50 100644 --- a/packages/typert/protocol/src/types.ts +++ b/packages/typert/protocol/src/types.ts @@ -878,15 +878,16 @@ export interface TypertContextRegistry { subscribe(listener: TypertRegistryListener): TypertDisposer } -/** Default wire field of one resource kind. */ -export interface TypertResourceDeclaration { - /** Top-level or first-object wire field holding one identity of the kind. */ - readonly field: string -} +/** Default identity extraction for one resource kind. */ +export type TypertResourceDeclaration = + | { readonly field: string } + | { + /** Called once with spread positional wire arguments. Undefined means no matching resource. */ + resolve(...args: readonly unknown[]): string | undefined + } -/** One registered resource kind with its default wire field. */ -export interface TypertResourceEntry extends TypertResourceDeclaration { - /** Registered resource kind. */ +/** One registered resource kind with its default identity extraction. */ +export type TypertResourceEntry = TypertResourceDeclaration & { readonly kind: string } diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index c64817c819..15a5e46273 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -461,7 +461,7 @@ class ResourceStore { return { register: (kind, declaration) => this.register(ctx, kind, declaration), get: kind => this.declarations.get(kind)?.provider, - entries: () => [...this.declarations].map(([kind, entry]) => Object.freeze({ kind, field: entry.provider.field })), + entries: () => [...this.declarations].map(([kind, entry]) => Object.freeze({ kind, ...entry.provider })), subscribe: listener => this.changes.subscribe(ctx, listener), } } @@ -471,9 +471,9 @@ class ResourceStore { if (kind === GLOBAL_RESOURCE_KIND) { throw new Error(`typert: resource kind "${GLOBAL_RESOURCE_KIND}" is reserved for the built-in global resource`) } - validateWireName('resource field', declaration.field) + if ('field' in declaration) validateWireName('resource field', declaration.field) if (this.declarations.has(kind)) throw new Error(`typert: resource kind "${kind}" is already registered`) - const entry: ProviderEntry = { provider: { field: declaration.field }, owner: {} } + const entry: ProviderEntry = { provider: { ...declaration }, owner: {} } const { declarations, changes } = this return ctx.effect(function* () { declarations.set(kind, entry) From 94464e2214439e0e6e2103769f4b8bd3ac434559 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:09:29 +0800 Subject: [PATCH 36/46] chore(session): sync baseline docs and test lint --- ...-session-history-stream-and-snapshot.zh.md | 110 ------------------ docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/subsystems/session.md | 14 +-- docs/subsystems/session.zh.md | 14 +-- .../tests/agent.host.spec.ts | 3 +- .../tests/remote-access.host.spec.ts | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 10 +- 8 files changed, 12 insertions(+), 145 deletions(-) delete mode 100644 .agents/notes/proposed/architecture/2026-10-21-session-history-stream-and-snapshot.zh.md diff --git a/.agents/notes/proposed/architecture/2026-10-21-session-history-stream-and-snapshot.zh.md b/.agents/notes/proposed/architecture/2026-10-21-session-history-stream-and-snapshot.zh.md deleted file mode 100644 index f6565f9dec..0000000000 --- a/.agents/notes/proposed/architecture/2026-10-21-session-history-stream-and-snapshot.zh.md +++ /dev/null @@ -1,110 +0,0 @@ -# Agent Note: Session 历史快照、纯监听与 Web Follow 聚合 - -Status: proposed - -## Problem - -历史的一次读取、实时事件订阅和 Web 开场聚合需要不同的生命周期。若纯监听也读取 Observation 来计算起点,订阅就引入不必要的历史读取;若聚合层另建历史读取模块,又会让标准接口与 Web 各自拥有一条读取路径。需要明确资源持有和接续顺序,使历史快照与实时事件能组合而不重复读取、不遗漏开场期间的事件。 - -## Proposal - -在 session-controller 包内保留并行的 `NewSessionController`(`newSession`)与 `NewWebController`(`newWeb`)。旧 `SessionController`、旧客户端与旧 API 不变;新接口仅交付 History Record,不包含 header、projection、assistant stream 或 activate/resume。 - -### 职责与接口 - -| 所有者 | 职责 | 不承担的工作 | -|---|---|---| -| `NewSessionController.history` | 取得 Observation,执行 `paginate`、`pageRecords`,返回 `{ cursor, records, hasMore }`,随后释放租约 | 不监听事件、不激活 | -| `NewSessionController.observeHistory` | 创建 Listener,发出无 cursor 的 `ready`,转发 `listener.events()` | 不读取历史、不取得 Observation | -| `NewWebController.follow` | 先创建 Listener,再在 Host 内直接调用 `ctx.newSessionController.history`,发 snapshot 后转发 `eventsAfter(cursor)` | 不另建 `historyRead` 模块、不经 RPC 调用标准方法 | -| `HistoryListener` | 注册 `session/event` 和 `session/created` 适配,缓冲、去重、检查连续性,以 `entryFor` 转为事件帧并输出,关闭时释放监听 | 不取得或持有 Observation、不补读快照、不激活 | - -`history` 接收 `address`、可选的 `throughSeq`、`beforeSeq` 与分页预算。不传 `throughSeq` 时,返回的 cursor 是本次 Observation 的末尾;传入时必须是该 Observation 可读取的有效点位。`beforeSeq` 只选择更早的页面,不替代基准 cursor;返回 cursor 不等于旧页最后一条记录的位置。`follow` 只接收 address 和开场分页预算,不接受旧 `throughSeq` 或 `beforeSeq`,开场读取当前点位的最新页。 - -复用已有 [SessionObservation](../../../../packages/session-query/session-query/src/observation.ts) 定义及 [Observation 决策](../../implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md),不引入来自 #4107 的新概念。`acquireObservation` 仅表示现有 `sessionQuery.observeSession` 加地址校验与错误映射;租约只在 `history` 方法生成返回值期间持有,不转交给 Listener,也不绑定流的整个生命周期。 - -### 三个入口 - -以下是职责示意,不作为可编译声明。`resolveHistoryRequest` 表示入口的参数校验和显式默认值处理;请求取消在读取完成后、返回结果前再次检查。 - -```text -// NewSessionController -async history(request, signal) { - const spec = resolveHistoryRequest(request) - using observation = await acquireObservation(this.ctx, spec.address, signal) - signal.throwIfAborted() - const cursor = spec.throughSeq ?? observation.cursor - validateHistoryCut(cursor, observation.cursor) - const page = paginate( - observation.events, spec.beforeSeq, spec.maxMessages, cursor, spec.turnWindow, - ) - return { cursor, records: pageRecords(page.events), hasMore: page.hasMore } -} -``` - -```text -// NewSessionController -async *observeHistory(request, signal) { - using listener = new HistoryListener(this.ctx, request.address, signal) - yield { type: 'ready' } - yield* listener.events() -} -``` - -```text -// NewWebController -async *follow(request, signal) { - using listener = new HistoryListener(this.ctx, request.address, signal) - const opening = await this.ctx.newSessionController.history(request, signal) - yield { type: 'snapshot', ...opening } - yield* listener.eventsAfter(opening.cursor) -} -``` - -### Listener 与快照接续 - -Listener 构造完成时已同步挂好事件监听并开始缓冲。`events()` 和 `eventsAfter(C)` 是同一个缓冲的两种单消费者选择,一次 Listener 生命周期只能选择并消费其中一种;不增加 `align` 或第二次初始化。两者返回异步事件帧序列,生成器延迟到首次迭代才执行,不影响构造时已开始的监听与缓冲。 - -- `events()` 输出订阅生效后收集的事件,以首个可输出事件建立序列起点,之后去重并检查连续性;不承诺交付订阅前的历史。 -- `eventsAfter(C)` 丢弃 `seq ≤ C` 的事件,第一条必须是 `C + 1`,随后保持连续;尚未收到下一条时等待,遇到缺号则明确结束并报告错误,不静默跳过或补读。 -- `session/created` 适配负责把构造期未逐条广播的种子事件纳入同一输入。这不调用 Observation,也不补读持久化快照;实现必须覆盖生成器尚未开始消费时发生创建的情况,不能依赖提前执行生成器来避免丢失。 - -例如 Listener 首先收到 15,读取 History 期间已缓冲 15~20,而 History 返回 `cursor = 20`。Follow 先发覆盖到 20 的 snapshot,再由 Listener 丢弃缓冲中重复的 15~20,从 21 开始输出增量。丢弃的是监听缓冲中的重叠,不是快照里的记录;不把快照故意截回 14。快照按页交付,更早记录仍通过 History 分页读取。 - -### Ready、授权与释放 - -`ready` 只确认该流的监听已安装,不包含 cursor,不证明会话存在、子会话身份已经验证或 Agent 就绪。独立组合接口的调用方持续消费并缓冲流,收到 ready 后再请求 History,应用历史页后按返回 cursor 丢弃重复增量;这保证远端请求顺序不会把读取排在监听生效之前。 - -Remote 方法继续声明现有 `resources` 会话授权;Host 内部直接调用不代替对 `follow` 入口本身的授权。纯流如何在不调用 Observation 的情况下完成子会话身份验证,是实现前必须核实的安全项,本提案不指定替代校验机制;不能把 ready 当成授权证据,也不能在必要身份校验未完成时输出事件数据。 - -History 成功、失败或取消都释放已取得的 Observation。流结束、调用方取消、插件卸载、历史读取失败及事件处理错误都必须撤销监听、释放缓冲并唤醒等待者;部分监听注册失败也必须清理已注册部分。Listener 不因没有新事件而阻止取消完成。 - -### 与既有决策及后续范围的关系 - -[历史与 Remote 事件传输决策](../../implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md)继续拥有旧 API、传输生命周期与客户端恢复行为;[Observation 决策](../../implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md)继续拥有精确读取和 projection 状态。两篇仍有独立作用,本提案不替代或归档它们。 - -History-only `newWeb.follow` 不是旧完整 Web 协议的直接替换;旧客户端迁移不在本阶段。Projection 下一阶段先讨论独立接口,再讨论 Follow 多数据聚合与同游标问题;本提案不预设共享 Observation、projection revision 或其他对齐方案。 - -## Alternatives considered - -**先读历史,再开 tail 补读缺口。** 不采用;先安装监听就能覆盖读取期间的增量,不需要额外历史读取或任意旧游标续传能力。 - -**纯监听取得 Observation 并返回 ready.cursor。** 不采用;监听只需要确认订阅生效,历史点位由独立 History 返回。 - -**另建 historyRead 模块或让 Listener 持有 Observation。** 不采用;标准 History 方法已经拥有一次读取,Listener 仅按结果 cursor 消费缓冲,二者无需共享资源所有权。 - -## Acceptance criteria - -- History 一次调用取得一份 Observation,返回与该读取一致的 cursor、records、hasMore;覆盖显式点位、最新点位、空历史、非法点位及旧页分页。 -- 纯监听不调用 Observation 或激活路径;ready 不含 cursor,始终先于事件帧,事件去重并保持已建立起点之后的连续性。 -- Follow 的监听注册先于 History 调用,且仅调用一次标准 History;开场第一帧是 snapshot,重叠例子中第一条增量为 21,生成器延迟消费期间的事件不丢失。 -- 覆盖监听期间 Session 创建、种子事件与普通通知重复、缺号、取消、消费者结束、读取失败及插件卸载;断言租约、监听与等待者全部释放。 -- 实现前核实纯流子会话身份验证;拒绝错误父子关系或身份不符的地址,不能以 ready 或 child id 过滤替代授权。Host 集成测试覆盖真实资源授权与直接方法调用路径。 -- 新增无密钥快照用例固定 history-only 的开场与增量输出;旧 API 与旧客户端的协议、激活行为保持不变。测试结果与类型检查在实现阶段提供,本提案不代表现有示例代码已通过验证。 - -## Risks - -- 纯流不提供历史起点或存在性结论;调用方必须正确理解 ready,并在需要历史时按先订阅后读取的顺序组合。 -- 开场读取慢或消费者停止拉取会积累缓冲。实现前需核实项目既有背压与容量策略;不能通过静默丢事件维持连接,本提案不新增容量配置。 -- 子会话身份校验和 `session/created` 在消费开始前的适配必须完成验证,不能因为新入口更短而削弱数据访问控制或事件完整性。 -- 新旧接口并行保留有暂时维护成本;命名相近不表示新 Follow 已提供旧 Web 的全部数据或副作用。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 40e0c8a254..cf0d89c6b9 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -243,7 +243,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-session-controller` - `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` -- `source`: [`packages/api/session-controller/src/index.ts:93`](../packages/api/session-controller/src/index.ts) +- `source`: [`packages/api/session-controller/src/index.ts:92`](../packages/api/session-controller/src/index.ts) ```ts config-catalog /** Session Controller deployment policy. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 5ad823ea33..edfd0f9567 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -245,7 +245,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-session-controller` - `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` -- `source`: [`packages/api/session-controller/src/index.ts:93`](../packages/api/session-controller/src/index.ts) +- `source`: [`packages/api/session-controller/src/index.ts:92`](../packages/api/session-controller/src/index.ts) ```ts config-catalog /** Session Controller deployment policy. */ diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index a412bfd6c4..4e1ef23338 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -839,14 +839,6 @@ Host service backing the generated `ctx.remote.session` namespace. */ resolveAgent(sessionId: SessionId): Promise -/** - * Inspect one attached or persisted Session without activating its Agent. - * @param sessionId - durable Session identity. - * @param signal - optional caller cancellation for persistence reads. - * @returns the current attached state or persisted header and event prefix. - */ -inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise - /** * Read all visible Session rows without resuming an Agent. * @param _request - reserved empty list request. @@ -972,7 +964,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * @param signal - cancellation for persistence reads. * @returns one chronological page. */ -@Remote({ name: 'page', resources: { session: addressedSession } }) page(request: SessionPageRequest, signal: AbortSignal): Promise +@Remote('page') page(request: SessionPageRequest, signal: AbortSignal): Promise /** * Follow one Session log from its opening or resume cursor. @@ -981,7 +973,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * @returns a complete opening snapshot followed by gap-free durable event * frames and optional cursorless assistant-stream frames. */ -@Remote({ mode: 'stream', resources: { session: addressedSession } }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable +@Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable /** * Read exact projections, returning cached hints only when migration is required. @@ -999,7 +991,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | @Remote({ mode: 'stream' }) async *control(signal: AbortSignal): AsyncIterable ``` -Types: [SessionId](core.md) · [SessionInspection](persistence.md) · [SessionSearchRequest](session-query.md) +Types: [SessionId](core.md) · [SessionSearchRequest](session-query.md) Source: [`packages/api/session-controller/src/index.ts`](../../packages/api/session-controller/src/index.ts) diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 9ac7ef6733..57e732d08c 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -843,14 +843,6 @@ Host service backing the generated `ctx.remote.session` namespace. */ resolveAgent(sessionId: SessionId): Promise -/** - * Inspect one attached or persisted Session without activating its Agent. - * @param sessionId - durable Session identity. - * @param signal - optional caller cancellation for persistence reads. - * @returns the current attached state or persisted header and event prefix. - */ -inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise - /** * Read all visible Session rows without resuming an Agent. * @param _request - reserved empty list request. @@ -976,7 +968,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * @param signal - cancellation for persistence reads. * @returns one chronological page. */ -@Remote({ name: 'page', resources: { session: addressedSession } }) page(request: SessionPageRequest, signal: AbortSignal): Promise +@Remote('page') page(request: SessionPageRequest, signal: AbortSignal): Promise /** * Follow one Session log from its opening or resume cursor. @@ -985,7 +977,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * @returns a complete opening snapshot followed by gap-free durable event * frames and optional cursorless assistant-stream frames. */ -@Remote({ mode: 'stream', resources: { session: addressedSession } }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable +@Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable /** * Read exact projections, returning cached hints only when migration is required. @@ -1003,7 +995,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | @Remote({ mode: 'stream' }) async *control(signal: AbortSignal): AsyncIterable ``` -Types: [SessionId](core.zh.md) · [SessionInspection](persistence.zh.md) · [SessionSearchRequest](session-query.zh.md) +Types: [SessionId](core.zh.md) · [SessionSearchRequest](session-query.zh.md) Source: [`packages/api/session-controller/src/index.ts`](../../packages/api/session-controller/src/index.ts) diff --git a/packages/api/session-controller/tests/agent.host.spec.ts b/packages/api/session-controller/tests/agent.host.spec.ts index 45a6d7e29a..915bfff20f 100644 --- a/packages/api/session-controller/tests/agent.host.spec.ts +++ b/packages/api/session-controller/tests/agent.host.spec.ts @@ -6,7 +6,7 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' -import SessionStore, { SESSION_FORMAT_VERSION, SessionLogOffset, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { SessionHeader } from '@deepseek-ai/dsh-session' import { SessionAlreadyOwnedError } from '@deepseek-ai/dsh-session-persistence' import type { SessionObservation } from '@deepseek-ai/dsh-session-query' @@ -15,7 +15,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { ApiSessionAgentController, ApiSessionCwdConflict, - ApiSessionNotFound, } from '../src/agent.ts' import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts' diff --git a/packages/api/session-controller/tests/remote-access.host.spec.ts b/packages/api/session-controller/tests/remote-access.host.spec.ts index 824eb5f702..7fff981917 100644 --- a/packages/api/session-controller/tests/remote-access.host.spec.ts +++ b/packages/api/session-controller/tests/remote-access.host.spec.ts @@ -118,7 +118,7 @@ describe('Session Controller access', () => { if (resource === undefined || !('resolve' in resource)) throw new Error('Session resource resolver is missing') for (const method of ['page', 'follow']) { const reader = remoteMethods(controller).find(marker => marker.method === method)?.resources?.['session'] - ?? resource.resolve + ?? ((...args: readonly unknown[]) => resource.resolve(...args)) expect(reader?.({ address: { kind: 'session', sessionId: SHARED } })).toBe(SHARED) // A subagent address reads the child transcript; whether the parent's access covers it is the policy's rule. expect(reader?.({ address: { kind: 'subagent', parentSessionId: SHARED, childSessionId: PRIVATE } })).toBe(PRIVATE) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 93ad1f6b22..02ca2256f3 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2262,12 +2262,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'sessionId', description: 'Session identity whose Agent owns the operation.' }], returns: 'the live Agent or the stable Session-domain failure.', }, - { - signature: 'inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise', - description: 'Inspect one attached or persisted Session without activating its Agent.', - parameters: [{ name: 'sessionId', description: 'durable Session identity.' }, { name: 'signal', description: 'optional caller cancellation for persistence reads.' }], - returns: 'the current attached state or persisted header and event prefix.', - }, { signature: '@Remote(\'list\') async list(_request: SessionListRequest, signal: AbortSignal): Promise', description: 'Read all visible Session rows without resuming an Agent.', @@ -2367,13 +2361,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'acknowledgement that cancellation was requested.', }, { - signature: '@Remote({ name: \'page\', resources: { session: addressedSession } }) page(request: SessionPageRequest, signal: AbortSignal): Promise', + signature: '@Remote(\'page\') page(request: SessionPageRequest, signal: AbortSignal): Promise', description: 'Read one cold-safe, message-aligned Session history page.', parameters: [{ name: 'request', description: 'durable address, backward cursor, and page budget.' }, { name: 'signal', description: 'cancellation for persistence reads.' }], returns: 'one chronological page.', }, { - signature: '@Remote({ mode: \'stream\', resources: { session: addressedSession } }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable', + signature: '@Remote({ mode: \'stream\' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable', description: 'Follow one Session log from its opening or resume cursor.', parameters: [{ name: 'request', description: 'durable address and last committed sequence already held by the caller.' }, { name: 'signal', description: 'cancellation owned by the Remote stream carrier.' }], returns: 'a complete opening snapshot followed by gap-free durable event frames and optional cursorless assistant-stream frames.', From 05f94de1f9a4879820aad591c4035fd62661c1f3 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:55:14 +0800 Subject: [PATCH 37/46] fix(typert): preserve parameter metadata in resource resolvers --- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- packages/api/gateway/src/access.ts | 8 +++---- .../gateway/tests/gateway-access.host.spec.ts | 15 ++++++++---- packages/api/session-controller/src/index.ts | 22 ++++++++++++------ .../tests/remote-access.host.spec.ts | 23 ++++++++++++++++++- packages/typert/protocol/README.i18n.yaml | 4 ++-- packages/typert/protocol/README.md | 2 +- packages/typert/protocol/README.zh.md | 2 +- packages/typert/protocol/src/index.ts | 1 + packages/typert/protocol/src/types.ts | 10 ++++++-- 11 files changed, 67 insertions(+), 24 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index cf0d89c6b9..585c58bccc 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -243,7 +243,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-session-controller` - `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` -- `source`: [`packages/api/session-controller/src/index.ts:92`](../packages/api/session-controller/src/index.ts) +- `source`: [`packages/api/session-controller/src/index.ts:100`](../packages/api/session-controller/src/index.ts) ```ts config-catalog /** Session Controller deployment policy. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index edfd0f9567..ef09ec3bcb 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -245,7 +245,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-session-controller` - `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` -- `source`: [`packages/api/session-controller/src/index.ts:92`](../packages/api/session-controller/src/index.ts) +- `source`: [`packages/api/session-controller/src/index.ts:100`](../packages/api/session-controller/src/index.ts) ```ts config-catalog /** Session Controller deployment policy. */ diff --git a/packages/api/gateway/src/access.ts b/packages/api/gateway/src/access.ts index b9c5ae42be..b4a27485c6 100644 --- a/packages/api/gateway/src/access.ts +++ b/packages/api/gateway/src/access.ts @@ -22,9 +22,9 @@ type ResourceIds = readonly unknown[] | 'invalid' * `resources` reader of the method wins; otherwise every lookup parameter * whose provider declares the kind contributes its wire resource id, and so * does the Context identity of a Context method whose Host adapter declares - * the kind; otherwise the kind's default field registered in - * `typert.resources` is read from a top-level argument or, failing that, - * from the first object argument. + * the kind; otherwise the kind's registered default resolver receives named + * wire values and ordered parameter descriptions. A default field instead + * reads a top-level argument or, failing that, the first object argument. * @param typert - registry holding default fields, lookup providers, and Host Context adapters. * @param readers - the method's `resources` readers, or `undefined` when it declares none. * @param descriptor - receiver, parameter order, lookups, and cancellation slot of the method. @@ -53,7 +53,7 @@ export function callResources( for (const declaration of typert.resources.entries()) { sources.set(declaration.kind, () => { if ('resolve' in declaration) { - return read(() => declaration.resolve(...positionalArgs), []) + return read(() => declaration.resolve({ args, parameters: descriptor.parameters }), []) } const id = defaultFieldId(descriptor, args, declaration.field) return Array.isArray(id) ? 'invalid' : id === undefined ? [] : [id] diff --git a/packages/api/gateway/tests/gateway-access.host.spec.ts b/packages/api/gateway/tests/gateway-access.host.spec.ts index 374fcf0cfa..672764ffc9 100644 --- a/packages/api/gateway/tests/gateway-access.host.spec.ts +++ b/packages/api/gateway/tests/gateway-access.host.spec.ts @@ -21,6 +21,7 @@ import { type TypertContext, type TypertLookup, type TypertResource, + type TypertResourceResolveInput, } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import { z } from 'zod' @@ -872,8 +873,12 @@ describe('resource resolution', () => { const ctx = new Context() roots.push(ctx) await ctx.plugin(TypertRegistry) - const resolve = vi.fn((request?: string | { address?: { id: string } }) => { - return typeof request === 'string' ? request : request?.address?.id + const resolve = vi.fn(({ args, parameters }: TypertResourceResolveInput) => { + const named = parameters.find(parameter => parameter.wire === 'roomId') + if (named !== undefined) return args[named.wire] as string | undefined + const first = parameters[0] + const request = first === undefined ? undefined : args[first.wire] as { address?: { id: string } } | undefined + return request?.address?.id }) const dispose = ctx.typert.resources.register('test-shelf', { resolve }) expect(callResources(ctx.typert, undefined, method('roomId'), { roomId: 'r1' }, NEVER)).toEqual([ROOM]) @@ -882,12 +887,14 @@ describe('resource resolution', () => { resolve.mockClear() const args = { other: {}, later: { address: { id: 'r1' } } } expect(callResources(ctx.typert, undefined, method('other', 'later'), args, NEVER)).toEqual([GLOBAL]) - expect(resolve).toHaveBeenCalledExactlyOnceWith(args.other, args.later) + expect(resolve).toHaveBeenCalledExactlyOnceWith({ args, parameters: method('other', 'later').parameters }) resolve.mockClear() expect(callResources(ctx.typert, undefined, { ...method('renamed'), cancellation: { parameter: 'signal' }, }, { renamed: { address: { id: 'r1' } } }, NEVER)).toEqual([ROOM]) - expect(resolve).toHaveBeenCalledExactlyOnceWith({ address: { id: 'r1' } }, NEVER) + expect(resolve).toHaveBeenCalledExactlyOnceWith({ + args: { renamed: { address: { id: 'r1' } } }, parameters: method('renamed').parameters, + }) expect(callResources(ctx.typert, { 'test-shelf': () => 'override' }, method('request'), { request: { address: { id: 'r1' } }, }, NEVER)).toEqual([shelf('override')]) diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index e6d0acb551..bed8b292c0 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -12,7 +12,7 @@ import { canOpenNativePath, nativeFileManager, nativeFileApplications, openNativ import type { SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-session-projection-cache' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' -import { Remote, RemoteError, TypertRemoteService, principalOf } from '@deepseek-ai/dsh-typert-protocol' +import { Remote, RemoteError, TypertRemoteService, principalOf, type TypertResourceResolveInput } from '@deepseek-ai/dsh-typert-protocol' import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time' import { assertNever } from '@deepseek-ai/dsh-util-values' import type {} from '@deepseek-ai/dsh-api-remote-access' @@ -79,13 +79,21 @@ declare module '@deepseek-ai/cordis' { /** * Extract the Session resource from an ID or a history address. - * @param request - first wire argument carrying a Session ID or address. - * @returns the target Session identity when present. + * @param input - validated wire arguments and their ordered parameter descriptions. + * @returns the named Session identity, otherwise the first object's Session identity or history address. */ -function resolveSessionResource(request?: { readonly sessionId?: SessionId; readonly address?: SessionAddress }): SessionId | undefined { - const address = request?.address - return request?.sessionId ?? (address === undefined ? undefined - : address.kind === 'session' ? address.sessionId : address.childSessionId) +function resolveSessionResource({ args, parameters }: TypertResourceResolveInput): SessionId | undefined { + const sessionParameter = parameters.find(parameter => parameter.name === 'sessionId' || parameter.wire === 'sessionId') + if (sessionParameter !== undefined) return args[sessionParameter.wire] as SessionId | undefined + for (const parameter of parameters) { + const value = args[parameter.wire] + if (typeof value !== 'object' || value === null || Array.isArray(value)) continue + const request = value as { readonly sessionId?: SessionId; readonly address?: SessionAddress } + const address = request.address + return request.sessionId ?? (address === undefined ? undefined + : address.kind === 'session' ? address.sessionId : address.childSessionId) + } + return undefined } /** Session Controller deployment policy. */ diff --git a/packages/api/session-controller/tests/remote-access.host.spec.ts b/packages/api/session-controller/tests/remote-access.host.spec.ts index 7fff981917..b69a7063a5 100644 --- a/packages/api/session-controller/tests/remote-access.host.spec.ts +++ b/packages/api/session-controller/tests/remote-access.host.spec.ts @@ -118,13 +118,34 @@ describe('Session Controller access', () => { if (resource === undefined || !('resolve' in resource)) throw new Error('Session resource resolver is missing') for (const method of ['page', 'follow']) { const reader = remoteMethods(controller).find(marker => marker.method === method)?.resources?.['session'] - ?? ((...args: readonly unknown[]) => resource.resolve(...args)) + ?? ((request: unknown) => resource.resolve({ args: { request }, parameters: [{ name: 'request', wire: 'request' }] })) expect(reader?.({ address: { kind: 'session', sessionId: SHARED } })).toBe(SHARED) // A subagent address reads the child transcript; whether the parent's access covers it is the policy's rule. expect(reader?.({ address: { kind: 'subagent', parentSessionId: SHARED, childSessionId: PRIVATE } })).toBe(PRIVATE) } }) + it('resolves named Session IDs and first-object requests without treating unrelated strings as identities', async () => { + const { ctx } = await harness() + const resource = ctx.typert.resources.get('session') + if (resource === undefined || !('resolve' in resource)) throw new Error('Session resource resolver is missing') + const resolve = (args: Readonly>, ...names: string[]) => resource.resolve({ + args, parameters: names.map(name => ({ name, wire: name })), + }) + expect(resolve({ sessionId: SHARED, zzz: 'other' }, 'sessionId', 'zzz')).toBe(SHARED) + expect(resolve({ options: { sessionId: SHARED }, yyy: 'other' }, 'options', 'yyy')).toBe(SHARED) + expect(resolve({ options: { address: { kind: 'session', sessionId: SHARED } }, xxx: 'other' }, 'options', 'xxx')).toBe(SHARED) + expect(resolve({ options: { address: { kind: 'subagent', parentSessionId: SHARED, childSessionId: PRIVATE } } }, 'options')).toBe(PRIVATE) + expect(resolve({ ns: SHARED, value: 'other' }, 'ns', 'value')).toBeUndefined() + expect(resolve({ ref: SHARED, value: 'other' }, 'ref', 'value')).toBeUndefined() + expect(resolve({ zzz: 'other', sessionId: SHARED }, 'zzz', 'sessionId')).toBe(SHARED) + expect(resolve({ first: {}, later: { sessionId: SHARED } }, 'first', 'later')).toBeUndefined() + expect(resolve({ count: 1, tags: [], empty: null, options: { sessionId: SHARED } }, 'count', 'tags', 'empty', 'options')).toBe(SHARED) + expect(resolve({}, 'sessionId')).toBeUndefined() + expect(resolve({})).toBeUndefined() + expect(resource.resolve({ args: { target: SHARED }, parameters: [{ name: 'sessionId', wire: 'target' }] })).toBe(SHARED) + }) + it('lets a global role call session/list and lists only the Sessions a Session role admits', async () => { const { ctx, as } = await harness() await ctx.plugin(TypertGatewayService) diff --git a/packages/typert/protocol/README.i18n.yaml b/packages/typert/protocol/README.i18n.yaml index c2c98225b5..018aebf137 100644 --- a/packages/typert/protocol/README.i18n.yaml +++ b/packages/typert/protocol/README.i18n.yaml @@ -21,8 +21,8 @@ en: 63968de5fc817d06 zh: fbe2804b2a2d70d3 /deepseek-ai-dsh-typert-protocol/use-this-package/identifying-the-caller-and-admitting-non-owner-callers: - en: 78e08ae732a8f6f4 - zh: be15fb7599994fb9 + en: 668e1adf84ba4c50 + zh: 56561e73ec79ee38 /deepseek-ai-dsh-typert-protocol/use-this-package/associating-host-objects-and-contexts-with-wire-identities: en: 3db384ae6f215632 zh: 56207184b09d4018 diff --git a/packages/typert/protocol/README.md b/packages/typert/protocol/README.md index b36b0eb211..8613555bf7 100644 --- a/packages/typert/protocol/README.md +++ b/packages/typert/protocol/README.md @@ -60,7 +60,7 @@ The invoker takes a caller-owned, mounted, unscoped Client Remote and resolves i `PeerScope.principal` is the caller identity that in-process Host code fixes when it creates the Peer; no wire field can set it, and `PeerScope.signal` aborts when the Peer scope ends. `PrincipalMap` is merge-extensible: each driver declares one JSON entry with a unique literal `kind` and no credential, because a principal can be persisted in a Session log; the built-in entry is `owner: { kind: 'owner' }`. A Remote method that records its caller on durable output calls `principalOf(this.ctx.invocation)`, which returns the principal of a non-owner caller and `undefined` for the owner and outside a Remote call, and writes the field only when it is defined. -Every Remote method is under access control: [`dsh-api-remote-access`](../../api/remote-access/README.md) decides whether a non-owner caller may reach it on the resources the call touches. `TypertResourceMap` is merge-extensible beside `TypertLookupMap`: the package that owns an identity type declares its resource kind as `TypertResource` (`dsh-session` declares `session: TypertResource`, `dsh-workspace` declares `workspace: TypertResource`). A `RemoteAccessResource` is either the built-in identity-less `{ kind: 'global' }` or one declared kind with its identity; resources are independent, so access to `global` implies nothing about any session. At runtime the domain that owns a kind registers its default wire field through `typert.resources.register(kind, { field })`, which rejects `global`; a lookup provider and a Host Context adapter name the kind their wire identity carries through `resource`, so a method with an Agent lookup parameter or an Agent-scoped receiver carries a `session` resource without options. The `resources` option of `@Remote` and `@RemoteScope` maps a kind to a reader that receives the method's wire arguments as its own parameters; their types are inferred from the decorated method, with lookup parameters mapped to their wire types, so `@Remote({ name: 'page', resources: { session: request => request.address.sessionId } })` reads a field the defaults cannot. A call whose readers and defaults name no resource touches `global`; they must name every resource the method accesses, because access control never checks one they omit. A forwarded `emit` entry of `TypertForwardableEventEntry` declares `resources` readers typed by its own event's parameters, and an entry without them touches `global`. Resource resolution belongs to the [API Gateway](../../api/gateway/README.md); decisions and refusal reasons belong to [`dsh-api-remote-access`](../../api/remote-access/README.md). +Every Remote method is under access control: [`dsh-api-remote-access`](../../api/remote-access/README.md) decides whether a non-owner caller may reach it on the resources the call touches. `TypertResourceMap` is merge-extensible beside `TypertLookupMap`: the package that owns an identity type declares its resource kind as `TypertResource` (`dsh-session` declares `session: TypertResource`, `dsh-workspace` declares `workspace: TypertResource`). A `RemoteAccessResource` is either the built-in identity-less `{ kind: 'global' }` or one declared kind with its identity; resources are independent, so access to `global` implies nothing about any session. At runtime the domain that owns a kind registers its default extraction through `typert.resources.register(kind, { field })` or `{ resolve }`, which rejects `global`. A default resolver receives `{ args, parameters }`: validated named wire values and ordered parameter descriptions containing `name` and `wire`, before lookup resolution; it returns an identity or `undefined` when absent. This lets a domain distinguish a named identity parameter from an unrelated string; a lookup provider and a Host Context adapter name the kind their wire identity carries through `resource`, so a method with an Agent lookup parameter or an Agent-scoped receiver carries a `session` resource without options. The `resources` option of `@Remote` and `@RemoteScope` maps a kind to a reader that receives the method's wire arguments as its own parameters; their types are inferred from the decorated method, with lookup parameters mapped to their wire types, so `@Remote({ name: 'page', resources: { session: request => request.address.sessionId } })` reads a field the defaults cannot. A call whose readers and defaults name no resource touches `global`; they must name every resource the method accesses, because access control never checks one they omit. A forwarded `emit` entry of `TypertForwardableEventEntry` declares `resources` readers typed by its own event's parameters, and an entry without them touches `global`. Resource resolution belongs to the [API Gateway](../../api/gateway/README.md); decisions and refusal reasons belong to [`dsh-api-remote-access`](../../api/remote-access/README.md). ### Associating Host objects and Contexts with wire identities diff --git a/packages/typert/protocol/README.zh.md b/packages/typert/protocol/README.zh.md index e99844fcc8..f02a8f5217 100644 --- a/packages/typert/protocol/README.zh.md +++ b/packages/typert/protocol/README.zh.md @@ -60,7 +60,7 @@ export class GoalService extends TypertRemoteService { `PeerScope.principal` 是进程内 Host 代码创建 Peer 时固定的调用方身份;任何 wire 字段都无法设置它,`PeerScope.signal` 则在 Peer scope 结束时中止。`PrincipalMap` 可合并扩展:每个驱动方声明一个 JSON 条目,带唯一的字面量 `kind` 且不含凭据,因为 principal 可能持久化到会话日志中;内置条目是 `owner: { kind: 'owner' }`。在持久输出上记录调用方的 Remote 方法调用 `principalOf(this.ctx.invocation)`:调用方不是 owner 时它返回该 principal,对 owner 以及在 Remote 调用之外返回 `undefined`;方法只在其有定义时写入该字段。 -每个 Remote 方法都受访问控制:由 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md) 判定非 owner 调用方能否在该调用触及的资源上调用它。`TypertResourceMap` 与 `TypertLookupMap` 并列、可合并扩展:拥有身份类型的包以 `TypertResource` 声明自己的资源种类(`dsh-session` 声明 `session: TypertResource`,`dsh-workspace` 声明 `workspace: TypertResource`)。`RemoteAccessResource` 要么是内置、无身份的 `{ kind: 'global' }`,要么是某个已声明种类加其身份;资源之间相互独立,`global` 上的访问不蕴含任何会话上的访问。运行时由拥有该种类的领域经 `typert.resources.register(kind, { field })` 登记默认 wire 字段,该登记拒绝 `global`;lookup provider 与 Host Context adapter 经 `resource` 声明其 wire 身份所属的种类,因此带 Agent 查找参数或 Agent 作用域接收器的方法无需选项即携带 `session` 资源。`@Remote` 与 `@RemoteScope` 的 `resources` 选项把种类映射到一个以方法 wire 参数为自身位置参数的读取函数;这些参数的类型从被装饰的方法推断,查找参数映射为其 wire 类型,因此 `@Remote({ name: 'page', resources: { session: request => request.address.sessionId } })` 可以读取默认规则读不到的字段。读取函数与默认规则都未给出资源的调用触及 `global`;它们必须给出方法访问的全部资源,因为访问控制从不检查被遗漏的资源。`TypertForwardableEventEntry` 中的转发 `emit` 条目可以声明 `resources` 读取函数,其类型由该事件自身的参数决定;未声明的条目触及 `global`。资源解析归 [API Gateway](../../api/gateway/README.zh.md);判定与拒绝原因归 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md)。 +每个 Remote 方法都受访问控制:由 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md) 判定非 owner 调用方能否在该调用触及的资源上调用它。`TypertResourceMap` 与 `TypertLookupMap` 并列、可合并扩展:拥有身份类型的包以 `TypertResource` 声明自己的资源种类(`dsh-session` 声明 `session: TypertResource`,`dsh-workspace` 声明 `workspace: TypertResource`)。`RemoteAccessResource` 要么是内置、无身份的 `{ kind: 'global' }`,要么是某个已声明种类加其身份;资源之间相互独立,`global` 上的访问不蕴含任何会话上的访问。运行时由拥有该种类的领域经 `typert.resources.register(kind, { field })` 或 `{ resolve }` 登记默认提取规则,该登记拒绝 `global`。默认 resolver 在 lookup 解析前接收 `{ args, parameters }`:已校验的命名 wire 值,以及包含 `name`、`wire` 的有序参数描述;返回身份,无匹配时返回 `undefined`。领域因此可以区分具名身份参数与无关字符串;lookup provider 与 Host Context adapter 经 `resource` 声明其 wire 身份所属的种类,因此带 Agent 查找参数或 Agent 作用域接收器的方法无需选项即携带 `session` 资源。`@Remote` 与 `@RemoteScope` 的 `resources` 选项把种类映射到一个以方法 wire 参数为自身位置参数的读取函数;这些参数的类型从被装饰的方法推断,查找参数映射为其 wire 类型,因此 `@Remote({ name: 'page', resources: { session: request => request.address.sessionId } })` 可以读取默认规则读不到的字段。读取函数与默认规则都未给出资源的调用触及 `global`;它们必须给出方法访问的全部资源,因为访问控制从不检查被遗漏的资源。`TypertForwardableEventEntry` 中的转发 `emit` 条目可以声明 `resources` 读取函数,其类型由该事件自身的参数决定;未声明的条目触及 `global`。资源解析归 [API Gateway](../../api/gateway/README.zh.md);判定与拒绝原因归 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md)。 ### 把 Host 对象与 Context 关联到 wire identity diff --git a/packages/typert/protocol/src/index.ts b/packages/typert/protocol/src/index.ts index e2af8932b6..ac8e23ecff 100644 --- a/packages/typert/protocol/src/index.ts +++ b/packages/typert/protocol/src/index.ts @@ -73,6 +73,7 @@ export type { TypertResourceReader, TypertResourceReaders, TypertResourceRegistry, + TypertResourceResolveInput, TypertLocalRegistry, TypertLookup, TypertLookupDefinition, diff --git a/packages/typert/protocol/src/types.ts b/packages/typert/protocol/src/types.ts index 6b1d658b50..a9b362cfb8 100644 --- a/packages/typert/protocol/src/types.ts +++ b/packages/typert/protocol/src/types.ts @@ -878,12 +878,18 @@ export interface TypertContextRegistry { subscribe(listener: TypertRegistryListener): TypertDisposer } +/** Validated wire values and their ordered business parameters for resource extraction. */ +export interface TypertResourceResolveInput { + readonly args: Readonly> + readonly parameters: readonly Pick[] +} + /** Default identity extraction for one resource kind. */ export type TypertResourceDeclaration = | { readonly field: string } | { - /** Called once with spread positional wire arguments. Undefined means no matching resource. */ - resolve(...args: readonly unknown[]): string | undefined + /** Called once before lookups resolve. Undefined means no matching resource. */ + resolve(input: TypertResourceResolveInput): string | undefined } /** One registered resource kind with its default identity extraction. */ From 9d7cd67a5146fdc645af417c1bc9f03958df9bd7 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:12:32 +0800 Subject: [PATCH 38/46] test(session): cover cold attachment without workspace --- .../commands-queue-attachment.host.spec.ts | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts index a23917b312..7d46edda4e 100644 --- a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts +++ b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts @@ -342,6 +342,7 @@ function event(type: string, seq: SessionSeq, data: unknown): SessionEvent { async function persistedController( events: SessionEvent[], readImage: (ref: ImageAttachmentRef) => Promise<{ ref: ImageAttachmentRef; data: Uint8Array }>, + workspace: Pick = { cwd: '/workspace' }, ): Promise<{ ctx: Context; controller: SessionCommandController; sessionId: SessionId }> { const ctx = new Context() await ctx.plugin(SessionStore) @@ -350,7 +351,7 @@ async function persistedController( version: SESSION_FORMAT_VERSION, id: sessionId, createdAt: 1, - cwd: '/workspace', + ...workspace, isSeeded: false, } ctx.provide('sessionPersistence', testSessionPersistence(ctx, { @@ -563,6 +564,23 @@ describe('Session attachment authorization', () => { } }) + it('rejects a cold Session without cwd before reading its referenced image', async () => { + const ref = imageRef('missing-cwd') + const readImage = vi.fn(async () => ({ ref, data: new Uint8Array([1]) })) + const fixture = await persistedController( + [event('user/message', SessionSeq(0), { id: 'image', role: 'user', source: { kind: 'user' }, content: [{ type: 'image', attachment: ref }] })], + readImage, + {}, + ) + try { + await expect(fixture.controller.attachment({ sessionId: fixture.sessionId, attachmentId: ref.attachmentId })) + .rejects.toMatchObject({ code: 'session/not-found', details: { sessionId: fixture.sessionId } }) + expect(readImage).not.toHaveBeenCalled() + } finally { + await fixture.ctx.fiber.dispose() + } + }) + it('maps a cold observation failure to an internal authorization error', async () => { const ctx = new Context() await ctx.plugin(SessionStore) From b2b3bf4568cce02f6b035f7de158b223f846266b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:01:20 +0800 Subject: [PATCH 39/46] fix(typert): reject ambiguous and multiple default resource identities --- ...peer-principal-and-remote-access.i18n.yaml | 12 ++--- ...-10-12-peer-principal-and-remote-access.md | 28 +++++----- ...-12-peer-principal-and-remote-access.zh.md | 28 +++++----- docs/api-gateway.i18n.yaml | 4 +- docs/api-gateway.md | 2 +- docs/api-gateway.zh.md | 2 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/subsystems/typert.i18n.yaml | 8 +-- docs/subsystems/typert.md | 4 +- docs/subsystems/typert.zh.md | 4 +- packages/api/gateway/README.i18n.yaml | 4 +- packages/api/gateway/README.md | 2 +- packages/api/gateway/README.zh.md | 2 +- packages/api/gateway/src/access.ts | 11 +++- .../gateway/tests/gateway-access.host.spec.ts | 14 +++++ packages/api/remote-access/README.i18n.yaml | 4 +- packages/api/remote-access/README.md | 2 +- packages/api/remote-access/README.zh.md | 2 +- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 +- packages/api/session-controller/README.zh.md | 2 +- packages/api/session-controller/src/index.ts | 4 ++ .../tests/remote-access.host.spec.ts | 53 +++++++++++++++---- packages/typert/protocol/src/types.ts | 14 ++--- packages/typert/registry/README.i18n.yaml | 8 +-- packages/typert/registry/README.md | 4 +- packages/typert/registry/README.zh.md | 4 +- packages/typert/registry/src/service.ts | 2 +- packages/typert/registry/tests/typert.spec.ts | 22 ++++++++ 30 files changed, 170 insertions(+), 86 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml index 9378bc4f0b..af7606e73f 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml @@ -33,11 +33,11 @@ en: a78a2410eb52f777 zh: a868bf1b0e690c9f /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/resources-and-resource-readers-deepseek-ai-dsh-typert-protocol-deepseek-ai-dsh-typert-registry: - en: 625b20552962e268 - zh: 1136c0fd7cdc6901 + en: b79a4e9401b44978 + zh: eaca7cd4d07f485a /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/endpoint-resources: - en: ec1677d8e338e115 - zh: 6bedd163f208eaa9 + en: 056a141b31270cba + zh: 3f6caafb3a13c192 /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/roles-bindings-and-policies-deepseek-ai-dsh-api-remote-access: en: e57f396239ea9f56 zh: ddb26278b1521ceb @@ -81,8 +81,8 @@ en: 89452c3c3f042e75 zh: 9bcad00a60c65303 /agent-note-peer-principals-and-method-level-remote-access-control/consequences/verification: - en: 2279a8c153cc5b89 - zh: 63c0b6462722f0a3 + en: d7d0909974609247 + zh: 6ebf20714f8d545b /agent-note-peer-principals-and-method-level-remote-access-control/consequences/costs-and-limits: en: 1e254cec680b1a37 zh: 06a31e057ba965aa diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md index c9894ae409..7e55c0d8ba 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md @@ -215,9 +215,9 @@ export function RemoteScope |---|---| | Syntax | `@Remote({ name: 'page', resources: { session: (request) => request.address.kind === 'session' ? request.address.sessionId : request.address.childSessionId } })`; a Context method writes `@RemoteScope('agent')` and gets its Session resource from the Agent Context's `resource: 'session'`, and writes `@RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } })` when it needs another resource. Most methods write no `resources` and get their resources from automatic resolution | | Resource kinds | The lower-level packages that declare the identity types merge the resource kinds: `session: TypertResource` in `@deepseek-ai/dsh-session/types` and `workspace: TypertResource` in `@deepseek-ai/dsh-workspace/types`. session-controller references the commands package, so a merge in session-controller would make commands reference session-controller and form a project-reference cycle. remote-access builds in `global`, which has no id and stays out of `TypertResourceMap`; typert reserves the name, and `typert.resources.register('global', …)` throws | -| Default fields and declarations | The domain that owns a kind registers its default wire field when its controller is constructed: the `SessionController` constructor calls `typert.resources.register('session', { field: 'sessionId' })` and the `WorkspaceController` constructor registers `workspace` → `workspaceId`; registering a kind twice throws. The Agent, Session, and `workspaceFileScope` lookups declare `resource: 'session'`, and the agent package's Agent Context declares `resource: 'session'` | -| Resolution precedence | Each resource kind takes, in order: ① that kind's reader in the method's `resources`; ② every lookup parameter that declares that kind as its `resource` contributes its wire identity, and an omitted optional lookup parameter contributes none; when the Context of a Context method declares that kind as its `resource`, the Context's wire identity contributes too; ③ that kind's default field: a top-level named argument of that name when one exists, otherwise that field of the first object argument. A default field names one identity, so an array there is an invalid value | -| Argument values | Resource resolution and the `args` of `decide()` read the named arguments validated against their Typert-compiled types: each JSON argument is decoded once by its codec, which drops object fields its type does not declare, and dispatch reuses that value, so a caller cannot change the resources with an undeclared field; lookup parameters and the Context identity keep their wire identities, and no lookup or Agent is resolved at that point | +| Default extraction declarations | The owning domain registers either `{ field }` or `{ resolve }`, never both. SessionController registers `resolveSessionResource`; WorkspaceController registers `workspaceId`. Duplicate kinds are rejected. Lookup and Host Context resource declarations remain independent of these defaults | +| Resolution precedence | Per kind: explicit method reader, then lookup and Host Context identities, then the registered default field or resolver. A field reads the named top-level parameter or the first object argument. A resolver receives validated named `args` and ordered parameter descriptions once before lookup resolution. Defaults return one non-empty string or undefined; arrays are invalid | +| Argument values | Compiled codecs strip undeclared object fields; SRC fallback checks JSON safety without stripping them. Resolution and dispatch use the same validated values. The Session resolver rejects a request object containing both sessionId and address, even when equal. Lookup and Context identities remain wire values until after authorization | | Resolution result | The resources of every kind together are the resources the call touches; with none, the call touches `[global]`; when resources resolve, only those are used and `global` is not added | | Readers | Called with the method's positional arguments: the named arguments mapped by the descriptor back to the method's parameter order (the validated value in a JSON position, the wire identity in a lookup position, and the call's signal in the cancellation position); return one id, an id array (every one must be allowed), or `undefined` (no resource of that kind) | | Invalid values | A reader that throws or returns an empty array, or a read value (including a Context's wire identity) that is not a non-empty string, makes the resources invalid: the Gateway refuses the call with `remote-access/denied` (reason `resource-invalid`) without asking `decide()` | @@ -228,7 +228,7 @@ export function RemoteScope #### Endpoint resources -The table lists the endpoints #3854 viewers use and where their resources come from. Only `session/page` and `session/follow` need explicit `resources`; the rest rely on a lookup's `resource` or the default field: +The table lists the endpoints #3854 viewers use and where their resources come from. Session history endpoints use the registered Session resolver; special method readers can override defaults: | Endpoint | Resource | Resolution source | Filtering inside the method | |---|---|---|---| @@ -241,14 +241,14 @@ The table lists the endpoints #3854 viewers use and where their resources come f | `llm/listProviders` | `global` | no resource | no | | `commands/list` | session: `args.agentId` (the Agent lookup's wire field) | Agent lookup `resource: 'session'` | no | | `goals/get` | session: `args.agentId` | Agent lookup `resource: 'session'` | no | -| `session/attachment` | session: `args.request.sessionId` | default field | no | -| `skills/list` | session: `args.request.sessionId` | default field | no | -| `session/fork` | session: `args.request.sessionId` | default field | no | -| `session/prompt` | session: `args.request.sessionId` | default field | no | -| `session/cancel` | session: `args.request.sessionId` | default field | no | -| `session/projections` | session: `args.request.sessionId` | default field | no | -| `session/page` | session: `args.request.address.sessionId` for a Session address; `args.request.address.childSessionId` for a subagent address | `resources` | no | -| `session/follow` | same as above | `resources` | no | +| `session/attachment` | session: `args.request.sessionId` | default resolver | no | +| `skills/list` | session: `args.request.sessionId` | default resolver | no | +| `session/fork` | session: `args.request.sessionId` | default resolver | no | +| `session/prompt` | session: `args.request.sessionId` | default resolver | no | +| `session/cancel` | session: `args.request.sessionId` | default resolver | no | +| `session/projections` | session: `args.request.sessionId` | default resolver | no | +| `session/page` | session: `args.request.address.sessionId` for a Session address; `args.request.address.childSessionId` for a subagent address | default resolver | no | +| `session/follow` | same as above | default resolver | no | | `remoteAccess/follow` | `global` | no resource; remote-access allows it for every principal | no | A subagent address reads the child transcript, so its resource is the child Session. A Client rereads the projections of known Sessions after a reconnect, including the parent of a subagent Session; a reader without a grant on that parent has its projection read refused. Whether a grant on a parent Session covers its subagent children is the policy's decision, that is, whether it gives a binding for the child Session; the generic layer derives no parent resource. @@ -635,8 +635,8 @@ The review rule's home is the "`@Remote` marks capability methods" section of [R | Check order | The owner calls any method normally without reading a policy; a method no role lists refuses non-owners with `remote-access/denied` (`no-allow`); the access check precedes lookups, so a refused call activates no Session | Gateway host spec | | Policy registration | One policy per non-owner principal kind, and a second registration throws; once the disposer withdraws it, that kind is refused with `no-allow`; it is withdrawn with its fiber | remote-access host spec | | Decisions | Every resource needs a matching binding (AND); a role without the action, an unequal resource, or a refusing condition does not allow; the refusal reason follows condition > `explain` > `no-allow`; `explain` never changes the decision; conditions receive args for calls but not for snapshots and list filtering; a throwing `bindings()`, condition, or `explain()` refuses with `policy-failed`, each policy is logged once between `invalidate()` calls, and on the event path the owner stream and the shared event source continue; `remoteAccess/follow` is allowed for every principal | remote-access host spec, Gateway host spec | -| Resource resolution | A default field (top-level or first object argument) yields its resource; a lookup parameter that declares that kind as its `resource` yields its wire identity; a Context method gets its Session resource from the Agent Context identity in args without activating the Agent, and may declare other resources through `resources`; a `resources` reader overrides its kind, an array yields several resources, and `undefined` none; with nothing the call is `[global]`; a throw, an empty array, or a value that is not a non-empty string (including a Context identity) is invalid, refused by the Gateway with `resource-invalid` without asking `decide()`; a Context that declares no `resource` resolves by the ordinary rules; resources and `args` read the validated arguments, so an undeclared nested field changes no resource and no condition sees it, and a JSON argument failing validation returns `gateway/input-invalid` before the access check | Gateway host spec (gateway-access) | -| Resource registry | `typert.resources` registers one default field per kind, rejects a second registration and `global`, and withdraws the registration with its fiber; a configured lookup resolver keeps the provider's `resource`; a Host Context adapter keeps its `resource` | typert-registry spec | +| Resource resolution | A default field or resolver yields one resource; a default resolver returning an array is invalid; a lookup parameter that declares that kind as its `resource` yields its wire identity; a Context method gets its Session resource from the Agent Context identity in args without activating the Agent, and may declare other resources through `resources`; a `resources` reader overrides its kind, an array yields several resources, and `undefined` none; with nothing the call is `[global]`; a throw, an empty array, or a value that is not a non-empty string (including a Context identity) is invalid, refused by the Gateway with `resource-invalid` without asking `decide()`; a Context that declares no `resource` resolves by the ordinary rules; resources and `args` read the validated arguments, with undeclared fields stripped only by compiled codecs, and a JSON argument failing validation returns `gateway/input-invalid` before the access check | Gateway host spec (gateway-access) | +| Resource registry | `typert.resources` registers one default field or resolver per kind, rejects a second registration and `global`, and withdraws the registration with its fiber; a configured lookup resolver keeps the provider's `resource`; a Host Context adapter keeps its `resource` | typert-registry spec | | Forwarded events | A non-owner `$events` stream receives an event, whole, only when `event:` is granted on its resources; an event that declares no resources is decided on `global`; an event whose reader returns `undefined` does not reach non-owners, and `decide()` is not asked; the owner receives all | Gateway host spec (gateway-access) | | First snapshot | `snapshotFor()` returns the owner baseline or the principal's grants; remote-access injects the snapshot of the Peer each index speaks for; the Client starts from the injected baseline and rejects a malformed one | remote-access host and client specs | | Type inference | The parameter types of `resources` readers are inferred from the method; lookup positions have wire types; a missing property, more parameters than the method has, and a `global` key fail compilation; a binding whose resource kind differs from its role's fails compilation | typert-protocol spec, remote-access spec (type tests) | diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md index ed124e8159..47f2ebf445 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md @@ -215,9 +215,9 @@ export function RemoteScope |---|---| | 写法 | `@Remote({ name: 'page', resources: { session: (request) => request.address.kind === 'session' ? request.address.sessionId : request.address.childSessionId } })`;Context 方法写 `@RemoteScope('agent')`,由 Agent Context 的 `resource: 'session'` 得到会话资源,需要其他资源时写 `@RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } })`。多数方法不写 `resources`,由自动解析得到资源 | | 资源种类 | 资源种类由声明 id 类型的底层包合并:`session: TypertResource` 在 `@deepseek-ai/dsh-session/types`,`workspace: TypertResource` 在 `@deepseek-ai/dsh-workspace/types`。session-controller 引用 commands 包,若在 session-controller 合并,commands 要引用 session-controller,形成 project reference 环。`global` 由 remote-access 内置,没有 id,不进 `TypertResourceMap`;typert 保留该名,`typert.resources.register('global', …)` 抛错 | -| 默认字段与声明 | 拥有该种类的领域在控制器构造时登记默认 wire 字段:`SessionController` 构造函数调用 `typert.resources.register('session', { field: 'sessionId' })`,`WorkspaceController` 构造函数登记 `workspace` → `workspaceId`;同一种类重复登记即抛错。Agent、Session 与 `workspaceFileScope` lookup 声明 `resource: 'session'`,agent 包的 Agent Context 声明 `resource: 'session'` | -| 解析优先级 | 对每个资源种类依次取:① 该方法 `resources` 中该种类的读取函数;② 声明了该种类 `resource` 的每个 lookup 参数贡献其 wire 身份,省略的可选 lookup 参数不贡献;Context 方法的 Context 声明了该种类 `resource` 时,其 wire 身份同样贡献;③ 该种类的默认字段:命名参数中有同名顶层参数就读它,否则读第一个对象参数的同名字段。默认字段只表示一个身份,读到数组即为非法值 | -| 参数来源 | 资源解析与 `decide()` 的 `args` 读取经 Typert 编译类型校验后的命名参数:每个 JSON 参数由其 codec 解码一次,类型未声明的对象字段被移除,分派复用该值,因此调用方无法用未声明字段改变资源;lookup 参数与 Context 身份取 wire 身份,此时不解析 lookup、不激活 Agent | +| 默认提取声明 | 资源所属领域登记 `{ field }` 或 `{ resolve }`,两者互斥。SessionController 登记 `resolveSessionResource`;WorkspaceController 登记 `workspaceId`。重复种类被拒绝;lookup 和 Host Context 的资源声明独立于默认规则 | +| 解析优先级 | 每个种类依次采用方法显式 reader、lookup 与 Host Context 身份、注册的默认字段或 resolver。字段读取同名顶层参数或首个对象参数。resolver 在 lookup 解析前调用一次,接收已校验的命名 `args` 及有序参数描述。默认规则返回一个非空字符串或 undefined,数组非法 | +| 参数来源 | 编译 codec 移除未声明的对象字段;SRC fallback 只检查 JSON 安全性,不移除字段。解析和分派使用相同的已校验值。Session resolver 拒绝同时包含 sessionId 与 address 的请求对象,即使两者相同。lookup 和 Context 身份在授权完成前保持 wire 值 | | 解析结果 | 各种类的资源合在一起即本次调用访问的资源;一个都没有即 `[global]`;解析出资源时只用这些资源,不附加 `global` | | 读取函数 | 以方法的位置参数调用:按描述符把命名参数映射回方法参数顺序(JSON 位为校验后的值,lookup 位为 wire 身份,取消参数位为调用的 signal);返回一个 id、id 数组(每个都须被允许)或 `undefined`(该种类无资源) | | 非法值 | 读取函数抛错或返回空数组,或读到的值(含 Context 的 wire 身份)不是非空字符串即资源非法:Gateway 不询问 `decide()`,直接以 `remote-access/denied`(reason `resource-invalid`)拒绝调用 | @@ -228,7 +228,7 @@ export function RemoteScope #### 端点的资源 -下表列出 #3854 读者用到的端点及其资源来源。只有 `session/page` 与 `session/follow` 需要显式 `resources`,其余靠 lookup 的 `resource` 或默认字段: +下表列出 #3854 读者用到的端点及其资源来源。Session 历史端点使用注册的 Session resolver;特殊方法可用显式 reader 覆盖默认规则: | 端点 | 资源 | 解析来源 | 方法内过滤 | |---|---|---|---| @@ -241,14 +241,14 @@ export function RemoteScope | `llm/listProviders` | `global` | 无资源 | 否 | | `commands/list` | session:`args.agentId`(Agent lookup 的 wire 字段) | Agent lookup `resource: 'session'` | 否 | | `goals/get` | session:`args.agentId` | Agent lookup `resource: 'session'` | 否 | -| `session/attachment` | session:`args.request.sessionId` | 默认字段 | 否 | -| `skills/list` | session:`args.request.sessionId` | 默认字段 | 否 | -| `session/fork` | session:`args.request.sessionId` | 默认字段 | 否 | -| `session/prompt` | session:`args.request.sessionId` | 默认字段 | 否 | -| `session/cancel` | session:`args.request.sessionId` | 默认字段 | 否 | -| `session/projections` | session:`args.request.sessionId` | 默认字段 | 否 | -| `session/page` | session:Session 地址读 `args.request.address.sessionId`;subagent 地址读 `args.request.address.childSessionId` | `resources` | 否 | -| `session/follow` | 同上 | `resources` | 否 | +| `session/attachment` | session:`args.request.sessionId` | 默认 resolver | 否 | +| `skills/list` | session:`args.request.sessionId` | 默认 resolver | 否 | +| `session/fork` | session:`args.request.sessionId` | 默认 resolver | 否 | +| `session/prompt` | session:`args.request.sessionId` | 默认 resolver | 否 | +| `session/cancel` | session:`args.request.sessionId` | 默认 resolver | 否 | +| `session/projections` | session:`args.request.sessionId` | 默认 resolver | 否 | +| `session/page` | session:Session 地址读 `args.request.address.sessionId`;subagent 地址读 `args.request.address.childSessionId` | 默认 resolver | 否 | +| `session/follow` | 同上 | 默认 resolver | 否 | | `remoteAccess/follow` | `global` | 无资源;remote-access 对任何 principal 放行 | 否 | subagent 地址读取的是子会话转录,因此其资源是子会话。Client 在重连后会重新读取已知会话的 projections,其中包括 subagent 会话的父会话;没有该父会话授权的读者,其 projection 读取被拒。父会话的授权是否覆盖其 subagent 子会话由 policy 决定,即是否为子会话给出 binding;通用层不推导父资源。 @@ -635,8 +635,8 @@ GlobalStandardProps.useWorkspaceAccess: (workspaceId: WorkspaceId, action: Remot | 鉴权顺序 | owner 调用任何方法照常分派且不读取 policy;没有 role 列出的方法对非 owner 以 `remote-access/denied`(`no-allow`)拒绝;访问检查早于 lookup,被拒调用不激活会话 | Gateway host spec | | policy 注册 | 每个非 owner principal kind 一个 policy,重复注册抛错;disposer 撤回后该 kind 以 `no-allow` 拒绝;随 fiber 撤回 | remote-access host spec | | 判定 | 每个资源都需要匹配的 binding(AND);role 不含该 action、资源不相等或 condition 拒绝都不放行;拒绝原因按 condition > `explain` > `no-allow` 取;`explain` 不改变结论;condition 在调用时收到 args,在快照与列表过滤时不收;`bindings()`、condition 或 `explain()` 抛错以 `policy-failed` 拒绝,每个 policy 在两次 `invalidate()` 之间只记录一次日志,事件路径上 owner 流与共享事件源照常;`remoteAccess/follow` 对任何 principal 放行 | remote-access host spec、Gateway host spec | -| 资源解析 | 默认字段(顶层或首个对象参数)得到对应资源;声明了该种类 `resource` 的 lookup 参数得到其 wire 身份;Context 方法从 args 中的 Agent Context 身份得到其 session 资源且不激活 Agent,另可经 `resources` 声明其他资源;`resources` 读取函数覆盖该种类,数组为多个资源,`undefined` 为无;都没有为 `[global]`;抛错、空数组或非非空字符串(含 Context 身份)为非法,Gateway 以 `resource-invalid` 拒绝且不询问 `decide()`;未声明 `resource` 的 Context 按普通规则解析;资源与 `args` 读取校验后的参数,未声明的嵌套字段不改变资源、condition 也读不到它,JSON 参数校验失败在访问检查前返回 `gateway/input-invalid` | Gateway host spec(gateway-access) | -| 资源注册表 | `typert.resources` 每个种类登记一个默认字段,拒绝重复登记与 `global`,并随 fiber 撤回;配置的 lookup resolver 保留 provider 的 `resource`;Host Context adapter 保留其 `resource` | typert-registry spec | +| 资源解析 | 默认字段或 resolver 得到一个资源,默认 resolver 返回数组为非法;声明了该种类 `resource` 的 lookup 参数得到其 wire 身份;Context 方法从 args 中的 Agent Context 身份得到其 session 资源且不激活 Agent,另可经 `resources` 声明其他资源;`resources` 读取函数覆盖该种类,数组为多个资源,`undefined` 为无;都没有为 `[global]`;抛错、空数组或非非空字符串(含 Context 身份)为非法,Gateway 以 `resource-invalid` 拒绝且不询问 `decide()`;未声明 `resource` 的 Context 按普通规则解析;资源与 `args` 读取校验后的参数,只有编译 codec 才移除未声明字段,JSON 参数校验失败在访问检查前返回 `gateway/input-invalid` | Gateway host spec(gateway-access) | +| 资源注册表 | `typert.resources` 每个种类登记一个默认字段或 resolver,拒绝重复登记与 `global`,并随 fiber 撤回;配置的 lookup resolver 保留 provider 的 `resource`;Host Context adapter 保留其 `resource` | typert-registry spec | | 转发事件 | 非 owner 的 `$events` 流只在 `event:` 于其资源上被授权时整条收到事件;未声明资源的事件按 `global` 判定;读取函数返回 `undefined` 的事件不送达非 owner,且不询问 `decide()`;owner 全部收到 | Gateway host spec(gateway-access) | | 首份快照 | `snapshotFor()` 返回 owner baseline 或该 principal 的 grant;remote-access 为每个 index 注入其所代表 Peer 的快照;Client 从注入的 baseline 起步并拒绝格式错误的快照 | remote-access host 与 client spec | | 类型推断 | `resources` 读取函数的参数类型从方法推断;lookup 位为 wire 类型;访问不存在的属性、参数多于方法参数与 `global` 键编译失败;binding 的资源种类与 role 不符编译失败 | typert-protocol spec、remote-access spec(类型测试) | diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 6ea6996144..f84e67f384 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.i18n.yaml @@ -6,8 +6,8 @@ en: 735eb4f22d5f6f71 zh: 697eef269b133039 /api-gateway/programming-model: - en: 78eb70d6c2ce6608 - zh: 826f0ccc206b7299 + en: 008d81750e966b5d + zh: c31ca07da77ffdcc /api-gateway/component-responsibilities: en: fe382a9bcc768cc2 zh: 78e3d50d59780c3d diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 103822c4aa..e5ccfb7ca6 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -12,7 +12,7 @@ Business services use `@Remote` or `@RemoteScope` to select the methods exposed `@RemoteScope(key)` first resolves an identity to a scoped Context through `ctx.typert.contexts`, then obtains the service from that Context and invokes the method. It applies when the method itself depends on scoped composition and does not need to receive objects such as `Agent` explicitly. -`@Remote` accepts an export name or `{ name?, mode?, resources? }`, and `@RemoteScope(key, option?)` accepts an export name, as in `@RemoteScope('agent', 'current')` below, or `{ name?, resources? }` as its second argument. `resources` holds per-kind readers that return the identities of the resources one call touches for access control; each reader receives the method's wire arguments as its own parameters, with types inferred from the decorated method. Most methods declare no readers, because the Gateway also reads identities from lookup parameters and scoped Contexts that declare a `resource`, and from the default fields in `typert.resources`; the [Typert subsystem](subsystems/typert.md) owns that order. +`@Remote` accepts an export name or `{ name?, mode?, resources? }`, and `@RemoteScope(key, option?)` accepts an export name, as in `@RemoteScope('agent', 'current')` below, or `{ name?, resources? }` as its second argument. `resources` holds per-kind readers that return the identities of the resources one call touches for access control; each reader receives the method's wire arguments as its own parameters, with types inferred from the decorated method. Most methods declare no readers, because the Gateway also reads identities from lookup parameters and scoped Contexts that declare a `resource`, and from the default fields or resolvers in `typert.resources`; the [Typert subsystem](subsystems/typert.md) owns that order. Services normally extend `TypertRemoteService` so the constructor explicitly binds the Cordis service key and default Remote namespace. A service that already has another base class can instead declare `readonly typertRemote = bindTypertRemote(this, serviceKey)`; both forms leave an inspectable public binding and do not depend on the compiler injecting a symbol into the constructor. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 8e2df4acbe..fb8201be74 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -14,7 +14,7 @@ `@RemoteScope(key)` 表示先通过 `ctx.typert.contexts` 把 identity 解析为一个作用域 Context,再从该 Context 取得服务并调用方法。它适用于方法本身依赖作用域组合、而不需要显式接收 `Agent` 等对象的情形。 -`@Remote` 接受导出名或 `{ name?, mode?, resources? }`;`@RemoteScope(key, option?)` 的第二个参数接受导出名(如下文的 `@RemoteScope('agent', 'current')`)或 `{ name?, resources? }`。`resources` 按资源种类给出读取函数,返回一次调用所访问资源的身份,供访问控制使用;每个读取函数以方法的 wire 参数作为自身参数,类型从被装饰方法推断。多数方法不声明读取函数,因为 Gateway 还会从声明了 `resource` 的 lookup 参数与作用域 Context 以及 `typert.resources` 中的默认字段读取身份;[Typert 子系统](subsystems/typert.zh.md)负责该顺序。 +`@Remote` 接受导出名或 `{ name?, mode?, resources? }`;`@RemoteScope(key, option?)` 的第二个参数接受导出名(如下文的 `@RemoteScope('agent', 'current')`)或 `{ name?, resources? }`。`resources` 按资源种类给出读取函数,返回一次调用所访问资源的身份,供访问控制使用;每个读取函数以方法的 wire 参数作为自身参数,类型从被装饰方法推断。多数方法不声明读取函数,因为 Gateway 还会从声明了 `resource` 的 lookup 参数与作用域 Context 以及 `typert.resources` 中的默认字段或 resolver 读取身份;[Typert 子系统](subsystems/typert.zh.md)负责该顺序。 服务通常继承 `TypertRemoteService`,让 Cordis 服务 key 与默认 Remote namespace 在构造器中显式绑定。已有其他基类的服务可以改为声明 `readonly typertRemote = bindTypertRemote(this, serviceKey)`;两种方式都会留下可检查的公开 binding,不依赖编译器向构造函数注入 symbol。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 585c58bccc..4f09e1c051 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -243,7 +243,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-session-controller` - `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` -- `source`: [`packages/api/session-controller/src/index.ts:100`](../packages/api/session-controller/src/index.ts) +- `source`: [`packages/api/session-controller/src/index.ts:104`](../packages/api/session-controller/src/index.ts) ```ts config-catalog /** Session Controller deployment policy. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index ef09ec3bcb..003c86b399 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -245,7 +245,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-session-controller` - `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` -- `source`: [`packages/api/session-controller/src/index.ts:100`](../packages/api/session-controller/src/index.ts) +- `source`: [`packages/api/session-controller/src/index.ts:104`](../packages/api/session-controller/src/index.ts) ```ts config-catalog /** Session Controller deployment policy. */ diff --git a/docs/subsystems/typert.i18n.yaml b/docs/subsystems/typert.i18n.yaml index d6917635cf..5ea5ab4b27 100644 --- a/docs/subsystems/typert.i18n.yaml +++ b/docs/subsystems/typert.i18n.yaml @@ -12,11 +12,11 @@ en: 21e88d68154c2c92 zh: 8c9e05c65ef0f34d /typert-remote-calls/remote-access-resources: - en: fa95f70437c07395 - zh: d4870b678b569a89 + en: 2e27d0745a4e3eac + zh: eb4ad825852c595b /typert-remote-calls/typert-registry: - en: 5827ef50011811c9 - zh: 21a9e7b368ba211a + en: d891e9390cf890da + zh: f45a31cc2ce2021e /typert-remote-calls/host-gateway: en: 58b923de12e48f18 zh: d3c2372d8becf65e diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index d68d4a1a36..9c0a98dff3 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -286,7 +286,7 @@ interface RemoteInvocation { ## Remote access resources -Every Remote method is under the access control of [`dsh-api-remote-access`](../../packages/api/remote-access/README.md): a non-owner call is allowed only when the policy for its principal kind binds, on every resource the call touches, a role that lists the endpoint action. The package that owns an identity type merge-declares its resource kind in `TypertResourceMap`; `global`, the built-in identity-less resource, is never a kind there. The Gateway reads a kind's identities from the `resources` readers of `@Remote({ resources })` or `@RemoteScope(key, { resources })`, else from a lookup parameter or scoped Context that declares the kind as its `resource`, else from the default field registered in `typert.resources`; a call that names no resource touches `global`. The [Peer principals Agent Note](../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md) owns the decision. +Every Remote method is under the access control of [`dsh-api-remote-access`](../../packages/api/remote-access/README.md): a non-owner call is allowed only when the policy for its principal kind binds, on every resource the call touches, a role that lists the endpoint action. The package that owns an identity type merge-declares its resource kind in `TypertResourceMap`; `global`, the built-in identity-less resource, is never a kind there. The Gateway reads a kind's identities from the `resources` readers of `@Remote({ resources })` or `@RemoteScope(key, { resources })`, else from a lookup parameter or scoped Context that declares the kind as its `resource`, else from the default field or resolver registered in `typert.resources`; a call that names no resource touches `global`. The [Peer principals Agent Note](../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md) owns the decision. ```ts type-equiv /** @@ -358,7 +358,7 @@ interface RemoteMethodOptions exten ## Typert registry -`ctx.typert` separates current-environment descriptors, explicitly selected Remote contributions, lookup providers, scoped Context providers, and the default fields of resource kinds. A lookup provider owns the stable wire declaration and default resolver; Host composition can configure an effect-scoped synchronous or asynchronous resolver for the same key, and unloading that configuration restores the default policy. Registrations are Cordis-owned effects and return awaitable disposers. +`ctx.typert` separates current-environment descriptors, explicitly selected Remote contributions, lookup providers, scoped Context providers, and the default fields or resolvers of resource kinds. A lookup provider owns the stable wire declaration and default resolver; Host composition can configure an effect-scoped synchronous or asynchronous resolver for the same key, and unloading that configuration restores the default policy. Registrations are Cordis-owned effects and return awaitable disposers. ```ts type-equiv /** Minimal Typert runtime consumed through dependency inversion. */ diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index 270e5509bd..8c19d9fb8b 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -286,7 +286,7 @@ interface RemoteInvocation { ## Remote 访问资源 -每个 Remote 方法都受 [`dsh-api-remote-access`](../../packages/api/remote-access/README.zh.md) 的访问控制:只有当该 principal 种类的 policy 在调用访问的每个资源上都绑定了列出该端点 action 的 role 时,非 owner 调用才被放行。拥有某个身份类型的包把其资源种类声明合并进 `TypertResourceMap`;内置的无身份资源 `global` 从不是其中的种类。Gateway 从 `@Remote({ resources })` 或 `@RemoteScope(key, { resources })` 的 `resources` 读取函数读取某个种类的身份,其次是把该种类声明为 `resource` 的 lookup 参数或作用域 Context,再次是在 `typert.resources` 中登记的默认字段;不指向任何资源的调用访问 `global`。决策由 [Peer 身份属性 Agent Note](../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md) 负责。 +每个 Remote 方法都受 [`dsh-api-remote-access`](../../packages/api/remote-access/README.zh.md) 的访问控制:只有当该 principal 种类的 policy 在调用访问的每个资源上都绑定了列出该端点 action 的 role 时,非 owner 调用才被放行。拥有某个身份类型的包把其资源种类声明合并进 `TypertResourceMap`;内置的无身份资源 `global` 从不是其中的种类。Gateway 从 `@Remote({ resources })` 或 `@RemoteScope(key, { resources })` 的 `resources` 读取函数读取某个种类的身份,其次是把该种类声明为 `resource` 的 lookup 参数或作用域 Context,再次是在 `typert.resources` 中登记的默认字段或 resolver;不指向任何资源的调用访问 `global`。决策由 [Peer 身份属性 Agent Note](../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md) 负责。 ```ts type-equiv /** @@ -358,7 +358,7 @@ interface RemoteMethodOptions exten ## Typert 注册表 -`ctx.typert` 分开保存当前环境的 descriptor、显式选择的 Remote contribution、lookup 提供方、作用域上下文提供方与资源种类的默认字段。lookup 提供方拥有稳定 wire 声明和默认 resolver;Host 组合可以为同一个 key 配置 effect-scoped 同步或异步 resolver,配置卸载后恢复默认策略。各项注册都是由 Cordis 持有的 effect,并返回可等待的 disposer。 +`ctx.typert` 分开保存当前环境的 descriptor、显式选择的 Remote contribution、lookup 提供方、作用域上下文提供方与资源种类的默认字段或 resolver。lookup 提供方拥有稳定 wire 声明和默认 resolver;Host 组合可以为同一个 key 配置 effect-scoped 同步或异步 resolver,配置卸载后恢复默认策略。各项注册都是由 Cordis 持有的 effect,并返回可等待的 disposer。 ```ts type-equiv /** Minimal Typert runtime consumed through dependency inversion. */ diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index da77f603e9..a9ddbc70b5 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/README.i18n.yaml @@ -15,8 +15,8 @@ en: d9859219cdb0f367 zh: 2a3cfa8cb786e327 /deepseek-ai-dsh-api-gateway/host-service-typertgatewayservice-ctx-key-typertgateway: - en: 891bab7fceee4f0a - zh: be55b40151f049a1 + en: b35573c6707d00d9 + zh: 619bbba900b0fb8f /deepseek-ai-dsh-api-gateway/client-service-clientremote-ctx-key-remote: en: 0916113cdbe794fb zh: 14893c2cba95bfd6 diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index fac42718bf..6518441122 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -32,7 +32,7 @@ The Host entry registers a trusted-host interceptor on Connection's shared `/api Every call names the Peer it speaks for: `InvokeRemoteRequest.peer` and the `peer` argument of `wireStream.open()` are required, and the Gateway never supplies a default Peer. Dispatch from a disposed Peer fails with `gateway/peer-disposed`, for unary calls, streams, `$events`, and `$events/result` alike, and wire paths check it before they read the payload. The Host service injects `remoteAccess` and does not start without [`dsh-api-remote-access`](../remote-access/README.md). After the endpoint and argument-field checks, every call validates its JSON arguments once against their Typert-compiled types, failing with `gateway/input-invalid`, and dispatch reuses the validated values. For a call whose Peer principal is not the owner, after that validation and before lookup and Context resolution, the Gateway resolves the resources the call touches and asks `ctx.remoteAccess.decide({ principal, action, resources, args })` with the endpoint as the action and the validated arguments as `args`, or refuses with reason `resource-invalid` without asking when the resources are invalid; a refused call throws `remote-access/denied` with details `{ action, reason }`, so a refused call fails before it activates any Session. An owner call is dispatched without resource resolution or a decision. -Resources are read from the arguments validated against their Typert-compiled types, which drop object fields those types do not declare, while lookup and Context identities keep their wire values. For each resource kind, a reader in the method's `@Remote({ resources })` or `@RemoteScope(key, { resources })` options wins; it is called with the method's positional arguments, where lookup positions hold wire identities and the cancellation position holds the call's signal, and is read from the class metadata of the root Service instance. Otherwise every lookup parameter whose provider declares that kind through `resource` contributes its wire identity, an omitted optional one contributing none, and a Context method whose Host Context adapter declares that kind contributes its Context identity, read from the arguments before the Context is resolved. Otherwise the kind's default field registered in `typert.resources` is read from a top-level argument of that name or, failing that, from the first object argument. A reader that throws or returns an empty list, a default field holding an array, or an identity, including a Context identity, that is not a non-empty string makes the resolution `invalid`; a call that names no resource touches the `global` resource alone, and a call that names resources touches only those. A forwarded event carries every resource its entry declares, so there a reader returning `undefined` also makes the resolution `invalid`, while an entry without readers touches `global`. +Resources are read from validated arguments: Typert-compiled codecs drop undeclared object fields, while SRC fallback checks JSON safety without stripping them; lookup and Context identities keep their wire values. For each resource kind, a reader in the method's `@Remote({ resources })` or `@RemoteScope(key, { resources })` options wins; it is called with the method's positional arguments, where lookup positions hold wire identities and the cancellation position holds the call's signal, and is read from the class metadata of the root Service instance. Otherwise every lookup parameter whose provider declares that kind through `resource` contributes its wire identity, an omitted optional one contributing none, and a Context method whose Host Context adapter declares that kind contributes its Context identity, read from the arguments before the Context is resolved. Otherwise the kind's declaration in `typert.resources` supplies either a default field or a synchronous resolver. A field reads a top-level argument of that name or, failing that, the first object argument. A resolver is called once with `{ args, parameters }`: named validated wire values and ordered parameter descriptions containing `name` and `wire`. Both defaults return one identity or `undefined`; arrays are invalid. An explicit reader may return several identities. A resolver or reader that throws, a reader returning an empty list, or an identity, including a Context identity, that is not a non-empty string makes the resolution `invalid`; a call that names no resource touches the `global` resource alone, and a call that names resources touches only those. A forwarded event carries every resource its entry declares, so there a reader returning `undefined` also makes the resolution `invalid`, while an entry without readers touches `global`. A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index 86ad50da63..34759301dd 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -32,7 +32,7 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle 每次调用都指明它所代表的 Peer:`InvokeRemoteRequest.peer` 与 `wireStream.open()` 的 `peer` 参数都是必填项,Gateway 从不提供默认 Peer。来自已释放 Peer 的分发以 `gateway/peer-disposed` 失败,一元调用、流、`$events` 与 `$events/result` 一律如此,wire 路径在读取 payload 之前检查。Host 服务注入 `remoteAccess`,没有 [`dsh-api-remote-access`](../remote-access/README.zh.md) 就不启动。每次调用在 endpoint 与参数字段检查之后按 Typert 编译类型校验一次 JSON 参数,失败为 `gateway/input-invalid`,分派复用校验后的值。对 Peer 的 principal 不是 owner 的调用,Gateway 在该校验之后、lookup 与 Context 解析之前解析调用访问的资源,并以端点为 action、校验后的参数为 `args` 询问 `ctx.remoteAccess.decide({ principal, action, resources, args })`,资源非法时不询问而以原因 `resource-invalid` 拒绝;被拒的调用抛出 details 为 `{ action, reason }` 的 `remote-access/denied`,因此被拒绝的调用在激活任何 Session 之前就失败。owner 的调用不解析资源、不询问判定即分派。 -资源从经 Typert 编译类型校验后的参数中读取,校验会移除类型未声明的对象字段,lookup 与 Context 身份保持 wire 值。对每个资源种类,方法 `@Remote({ resources })` 或 `@RemoteScope(key, { resources })` options 中的读取函数优先;它以方法的位置参数调用(lookup 位为 wire 身份,取消参数位为调用的 signal),从根 Service 实例的类元数据读取。否则 provider 经 `resource` 声明了该种类的每个 lookup 参数贡献其 wire 身份,省略的可选 lookup 参数不贡献;Host Context adapter 声明了该种类的 Context 方法贡献其 Context 身份,在解析 Context 之前直接从参数读取。再否则从同名顶层参数读取该种类在 `typert.resources` 中登记的默认字段,没有同名参数时读第一个对象参数的同名字段。读取函数抛错或返回空列表、默认字段读到数组、或某个身份(含 Context 身份)不是非空字符串时,解析结果为 `invalid`;不指明任何资源的调用只访问 `global` 资源,指明了资源的调用只访问这些资源。转发事件携带其条目声明的每个资源,因此在事件路径上读取函数返回 `undefined` 同样使解析结果为 `invalid`,而没有读取函数的条目访问 `global`。 +资源从已校验参数中读取:Typert 编译 codec 会移除未声明的对象字段,SRC fallback 只检查 JSON 安全性而不移除字段;lookup 与 Context 身份保持 wire 值。对每个资源种类,方法 `@Remote({ resources })` 或 `@RemoteScope(key, { resources })` options 中的读取函数优先;它以方法的位置参数调用(lookup 位为 wire 身份,取消参数位为调用的 signal),从根 Service 实例的类元数据读取。否则 provider 经 `resource` 声明了该种类的每个 lookup 参数贡献其 wire 身份,省略的可选 lookup 参数不贡献;Host Context adapter 声明了该种类的 Context 方法贡献其 Context 身份,在解析 Context 之前直接从参数读取。再否则使用该种类在 `typert.resources` 中登记的默认字段或同步 resolver。字段规则读取同名顶层参数,没有时读取第一个对象参数的同名字段。resolver 以 `{ args, parameters }` 调用一次:命名的已校验 wire 值,以及包含 `name`、`wire` 的有序参数描述。两种默认规则均返回单个身份或 `undefined`,数组无效。显式读取函数可以返回多个身份。resolver 或读取函数抛错、读取函数返回空列表、或某个身份(含 Context 身份)不是非空字符串时,解析结果为 `invalid`;不指明任何资源的调用只访问 `global` 资源,指明了资源的调用只访问这些资源。转发事件携带其条目声明的每个资源,因此在事件路径上读取函数返回 `undefined` 同样使解析结果为 `invalid`,而没有读取函数的条目访问 `global`。 支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 diff --git a/packages/api/gateway/src/access.ts b/packages/api/gateway/src/access.ts index b4a27485c6..c1a727c0ab 100644 --- a/packages/api/gateway/src/access.ts +++ b/packages/api/gateway/src/access.ts @@ -52,8 +52,15 @@ export function callResources( ] for (const declaration of typert.resources.entries()) { sources.set(declaration.kind, () => { - if ('resolve' in declaration) { - return read(() => declaration.resolve({ args, parameters: descriptor.parameters }), []) + if (declaration.resolve !== undefined) { + let id: unknown + try { + id = declaration.resolve({ args, parameters: descriptor.parameters }) + } catch (error) { + // A resolver failure cannot identify an authorized resource. + return 'invalid' + } + return Array.isArray(id) ? 'invalid' : id === undefined ? [] : [id] } const id = defaultFieldId(descriptor, args, declaration.field) return Array.isArray(id) ? 'invalid' : id === undefined ? [] : [id] diff --git a/packages/api/gateway/tests/gateway-access.host.spec.ts b/packages/api/gateway/tests/gateway-access.host.spec.ts index 672764ffc9..15e500c1d3 100644 --- a/packages/api/gateway/tests/gateway-access.host.spec.ts +++ b/packages/api/gateway/tests/gateway-access.host.spec.ts @@ -904,6 +904,20 @@ describe('resource resolution', () => { expect(callResources(ctx.typert, undefined, method('request'), { request: {} }, NEVER)).toBeUndefined() }) + it('rejects arrays from a default resolver instead of authorizing several identities', async () => { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(TypertRegistry) + for (const ids of [[], ['r1'], ['r1', 'r2']]) { + const dispose = ctx.typert.resources.register('test-shelf', { + // Exercise invalid results from an untyped provider at the authorization boundary. + resolve: () => ids as never, + }) + expect(callResources(ctx.typert, undefined, method(), {}, NEVER)).toBeUndefined() + await dispose() + } + }) + it('lets a reader replace the defaults for its kind, called with the positional wire arguments', async () => { const typert = await registry() const readers = { 'test-shelf': (ids: unknown) => ids } diff --git a/packages/api/remote-access/README.i18n.yaml b/packages/api/remote-access/README.i18n.yaml index 461af0d1f9..14239cd17a 100644 --- a/packages/api/remote-access/README.i18n.yaml +++ b/packages/api/remote-access/README.i18n.yaml @@ -15,8 +15,8 @@ en: a18ecc232e3c3933 zh: b8e43580403f6d77 /deepseek-ai-dsh-api-remote-access/host-service-remoteaccessservice-ctx-key-remoteaccess: - en: 8a4be795c7aa2ef0 - zh: 02550b975e2e8d61 + en: 757a6bdf95e824c6 + zh: e8ae9712ad28c2f3 /deepseek-ai-dsh-api-remote-access/client-face: en: d4cf8f646fdf2d39 zh: 247f643e991aa806 diff --git a/packages/api/remote-access/README.md b/packages/api/remote-access/README.md index ab35a6b598..f1ddf064d1 100644 --- a/packages/api/remote-access/README.md +++ b/packages/api/remote-access/README.md @@ -37,7 +37,7 @@ Every composition that mounts [API Gateway](../gateway/README.md) mounts this se A role `{ name, resource, actions }` is a plain value naming an action set over one resource kind, `global` or a kind declared in `TypertResourceMap`. A binding `{ role, resource, condition? }` grants the role on one resource of the role's kind. A policy answers `bindings(principal)` and an optional `explain(request)` synchronously from state it holds in memory. A request is allowed only when, on every resolved resource, some binding names that resource, its role lists the action, and it has no condition or its condition allows. Resources are independent: a grant on `global` implies nothing on any other resource, and no resource implies another. A condition receives `{ action, resource, args? }` and runs only for a binding whose resource and role match; `args` is absent for events, snapshots, and list filtering. `bindings()` runs once per `decide()`, once per `allows()` check, and once per snapshot computation; it is never cached across decisions, so time-dependent state applies to the next call. -The Gateway resolves resources from `@Remote` and `@RemoteScope` `resources` readers, lookup and Host Context adapter `resource` declarations, and the default fields in `typert.resources`, and the [API Gateway](../gateway/README.md) owns that order; this package reads no argument field. A call that names no resource touches the `global` resource alone, so only a `global` role can allow it; putting an action in a `global` role lets the principal call it on any resource the call does not name. The Gateway refuses a call whose resources are invalid with `resource-invalid` without asking `decide()`. +The Gateway resolves resources from `@Remote` and `@RemoteScope` `resources` readers, lookup and Host Context adapter `resource` declarations, and the default field or resolver declarations in `typert.resources`, and the [API Gateway](../gateway/README.md) owns that order; this package reads no argument field. A call that names no resource touches the `global` resource alone, so only a `global` role can allow it; putting an action in a `global` role lets the principal call it on any resource the call does not name. The Gateway refuses a call whose resources are invalid with `resource-invalid` without asking `decide()`. When `bindings()`, a condition, or `explain()` throws, the decision is refused with `policy-failed`; in a snapshot, a throwing `bindings()` leaves the principal no grant and a throwing condition excludes its action. A faulty rule therefore never breaks the shared `$events` source, the snapshot stream, or page rendering. The service logger reports the first failure of each policy after each `invalidate()` and each policy registration or removal; later failures of that policy stay silent until then. diff --git a/packages/api/remote-access/README.zh.md b/packages/api/remote-access/README.zh.md index 514f885b33..bcd8e77324 100644 --- a/packages/api/remote-access/README.zh.md +++ b/packages/api/remote-access/README.zh.md @@ -37,7 +37,7 @@ kind: "package-reference" role `{ name, resource, actions }` 是普通值,表示限定一种资源种类(`global` 或在 `TypertResourceMap` 中声明的种类)的 action 集合。binding `{ role, resource, condition? }` 在该 role 种类的一个资源上授予该 role。policy 根据预先载入内存的状态同步回答 `bindings(principal)` 与可选的 `explain(request)`。只有在每个已解析资源上都有某个 binding 指向该资源、其 role 列出该 action、且没有 condition 或 condition 放行时,请求才被放行。资源之间相互独立:`global` 上的授权不蕴含任何其他资源上的授权,任何资源也不蕴含另一个资源。condition 收到 `{ action, resource, args? }`,只对资源与 role 都匹配的 binding 调用;事件、快照与列表过滤时没有 `args`。`bindings()` 在每次 `decide()`、每次 `allows()` 判定与每次快照计算中各调用一次,不跨判定缓存,因此依赖时间的状态对下一次调用即时生效。 -Gateway 依次从 `@Remote` 与 `@RemoteScope` 的 `resources` 读取函数、lookup 与 Host Context adapter 的 `resource` 声明、`typert.resources` 中的默认字段解析资源,解析顺序由 [API Gateway](../gateway/README.zh.md) 负责;本包不读取任何参数字段。不指明任何资源的调用只访问 `global` 资源,因此只有 `global` 级 role 能放行;把某个 action 放进 `global` 级 role,等于允许该 principal 在调用未指明的任何资源上调用它。资源非法的调用由 Gateway 以 `resource-invalid` 拒绝,不询问 `decide()`。 +Gateway 依次从 `@Remote` 与 `@RemoteScope` 的 `resources` 读取函数、lookup 与 Host Context adapter 的 `resource` 声明、`typert.resources` 中的默认字段或 resolver 声明解析资源,解析顺序由 [API Gateway](../gateway/README.zh.md) 负责;本包不读取任何参数字段。不指明任何资源的调用只访问 `global` 资源,因此只有 `global` 级 role 能放行;把某个 action 放进 `global` 级 role,等于允许该 principal 在调用未指明的任何资源上调用它。资源非法的调用由 Gateway 以 `resource-invalid` 拒绝,不询问 `decide()`。 `bindings()`、condition 或 `explain()` 抛错时,本次判定以 `policy-failed` 拒绝;计算快照时,`bindings()` 抛错即该 principal 没有 grant,condition 抛错即该 action 不放行。有问题的规则因此不会破坏共享的 `$events` 事件源、快照流或页面渲染。每次 `invalidate()` 以及每次 policy 注册或撤回之后,服务日志对每个 policy 只报告第一次失败;此后该 policy 的失败不再记录,直到下一次上述变化。 diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 46ac8d032c..cf1badb15b 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1fdf707322fb6996 zh: 0204f7e9d4aa3cce /session-controller/use-this-package: - en: 966e2852184146bf - zh: 31e3587651d991be + en: 0c8945a47f87b5f7 + zh: b099d66521dca175 /session-controller/client-references: en: 41bdda1ac32df558 zh: aa5d0e325924066b diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index de8ee6e9aa..ae7cff7cb7 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -35,7 +35,7 @@ The Client journal validates current Session event envelopes before publishing f Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. A receipt names a staged file or a staged image, and an image receipt subjects the prompt to the same image-input check as an encoded image. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` normally reads an exact baseline, including current-format cold logs, and returns `kind: sequenced`. Only a read requiring migration returns `kind: migration-required` hints without `asOfSeq`; missing keys remain absent. It returns null for a missing Session. Fork callers may set `allowMigration: false` to receive `session/migration-required` instead of starting source migration; omission retains automatic migration. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Sequenced reads and live projection frames use the same sequence ordering; cached reads fill hints without replacing sequenced values. -Every Remote endpoint is access-controlled: a Peer whose principal is not the owner reaches an endpoint only when, on every resource the call touches, a binding of that principal kind's policy in [`dsh-api-remote-access`](../remote-access/README.md) grants a role listing the action ([Peer principals Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md)). The controller injects `remoteAccess` and registers the `session` resource kind with the default field `sessionId` through `typert.resources`. `session/page` and `session/follow` declare a `session` reader in `@Remote({ resources })` that reads `request.address.sessionId` for an ordinary Session address and `request.address.childSessionId` for a subagent address; whether access to the parent Session covers its children is the policy's rule. `session/projections` reads the Session resource from `request.sessionId`, so a reconnecting reader reloads only the projections of Sessions it may read. `session/list`, `session/search`, and `session/control` read no resource, so their calls touch the `global` resource and need a `global` role; for a non-owner caller, `session/list` returns only the rows, `session/search` only the hits, and `session/control` only the baseline projections and projection frames, whose Session `ctx.remoteAccess.allows()` admits for the same action, so that action also belongs to a `session` role; owner results are unfiltered. `session/prompt` builds the durable `user-rpc` message source in its Remote method and records `principal` only for a non-owner caller, read through `principalOf(this.ctx.invocation)`; a source without `principal` belongs to the owner. +Every Remote endpoint is access-controlled: a Peer whose principal is not the owner reaches an endpoint only when, on every resource the call touches, a binding of that principal kind's policy in [`dsh-api-remote-access`](../remote-access/README.md) grants a role listing the action ([Peer principals Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md)). The controller injects `remoteAccess` and registers a default resolver for the `session` resource kind through `typert.resources`. It reads a named `sessionId` parameter, otherwise the first object argument's `sessionId` or `address`; unrelated string parameters name no Session. An object containing both `sessionId` and `address` is invalid, even when they name the same Session. `session/page` and `session/follow` use this resolver to read `address.sessionId` for an ordinary Session address and `address.childSessionId` for a subagent address; whether access to the parent Session covers its children is the policy's rule. `session/projections` reads the Session resource from `request.sessionId`, so a reconnecting reader reloads only the projections of Sessions it may read. `session/list`, `session/search`, and `session/control` read no resource, so their calls touch the `global` resource and need a `global` role; for a non-owner caller, `session/list` returns only the rows, `session/search` only the hits, and `session/control` only the baseline projections and projection frames, whose Session `ctx.remoteAccess.allows()` admits for the same action, so that action also belongs to a `session` role; owner results are unfiltered. `session/prompt` builds the durable `user-rpc` message source in its Remote method and records `principal` only for a non-owner caller, read through `principalOf(this.ctx.invocation)`; a source without `principal` belongs to the owner. List rows carry `formatStatus` when available, so Clients can identify migration-required Sessions without requesting their projections. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 191821f434..0bbbd6ddaf 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -35,7 +35,7 @@ Client journal 在发布 follow 快照、live entry 或历史页之前验证当 每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。凭证指向一个暂存文件或一张暂存图片;图片凭证与编码图片一样要经过图片输入检查。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 正常读取准确基线,包括当前格式的冷日志,返回 `kind: sequenced`。只有需要迁移时才返回不带 `asOfSeq` 的 `kind: migration-required` 提示,缺少的键保持缺席。Session 不存在时返回 null。Fork 调用者可设置 `allowMigration: false`,在源历史需要迁移时收到 `session/migration-required`;省略则保持自动迁移。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。有序读取与实时 projection 帧使用相同的序号排序规则;缓存读取只补充提示,不替换有序值。 -每个 Remote 端点都受访问控制:principal 不是 owner 的 Peer 只有在本次调用访问的每个资源上,该 principal kind 在 [`dsh-api-remote-access`](../remote-access/README.zh.md) 中的 policy 都有某个 binding 授予列出该 action 的 role 时,才能调用该端点([Peer 身份属性 Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md))。控制器注入 `remoteAccess`,并经 `typert.resources` 以默认字段 `sessionId` 登记 `session` 资源种类。`session/page` 与 `session/follow` 在 `@Remote({ resources })` 中声明 `session` 读取函数:对普通 Session 地址读 `request.address.sessionId`,对 subagent 地址读 `request.address.childSessionId`;父会话的访问权是否覆盖其子会话由 policy 决定。`session/projections` 从 `request.sessionId` 读取会话资源,因此重连的读者只会重新读取它可读的会话的 projections。`session/list`、`session/search` 与 `session/control` 不读取资源,其调用访问 `global` 资源,需要 `global` role;对非 owner 调用方,`session/list` 只返回其 Session 被 `ctx.remoteAccess.allows()` 按同一 action 允许的行,`session/search` 只返回这类 Session 的命中,`session/control` 也只返回这类 Session 的 baseline projection 与 projection 帧,因此该 action 也要列在 `session` role 中;owner 的结果不过滤。`session/prompt` 在其 Remote 方法内构造持久的 `user-rpc` 消息 source,且只为非 owner 调用方记录经 `principalOf(this.ctx.invocation)` 读取的 `principal`;不带 `principal` 的 source 属于 owner。 +每个 Remote 端点都受访问控制:principal 不是 owner 的 Peer 只有在本次调用访问的每个资源上,该 principal kind 在 [`dsh-api-remote-access`](../remote-access/README.zh.md) 中的 policy 都有某个 binding 授予列出该 action 的 role 时,才能调用该端点([Peer 身份属性 Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md))。控制器注入 `remoteAccess`,并经 `typert.resources` 为 `session` 资源种类登记默认 resolver。它读取具名 `sessionId` 参数,否则读取第一个对象参数的 `sessionId` 或 `address`;无关字符串参数不指明 Session。对象同时包含 `sessionId` 和 `address` 时无效,即使两者指向同一 Session。`session/page` 与 `session/follow` 使用该 resolver:对普通 Session 地址读 `address.sessionId`,对 subagent 地址读 `address.childSessionId`;父会话的访问权是否覆盖其子会话由 policy 决定。`session/projections` 从 `request.sessionId` 读取会话资源,因此重连的读者只会重新读取它可读的会话的 projections。`session/list`、`session/search` 与 `session/control` 不读取资源,其调用访问 `global` 资源,需要 `global` role;对非 owner 调用方,`session/list` 只返回其 Session 被 `ctx.remoteAccess.allows()` 按同一 action 允许的行,`session/search` 只返回这类 Session 的命中,`session/control` 也只返回这类 Session 的 baseline projection 与 projection 帧,因此该 action 也要列在 `session` role 中;owner 的结果不过滤。`session/prompt` 在其 Remote 方法内构造持久的 `user-rpc` 消息 source,且只为非 owner 调用方记录经 `principalOf(this.ctx.invocation)` 读取的 `principal`;不带 `principal` 的 source 属于 owner。 列表行在状态可用时携带 `formatStatus`,Client 无需请求其投影即可识别需要迁移的 Session。 diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index bed8b292c0..c053b1573a 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -81,6 +81,7 @@ declare module '@deepseek-ai/cordis' { * Extract the Session resource from an ID or a history address. * @param input - validated wire arguments and their ordered parameter descriptions. * @returns the named Session identity, otherwise the first object's Session identity or history address. + * @throws Error when the request object supplies both sessionId and address. */ function resolveSessionResource({ args, parameters }: TypertResourceResolveInput): SessionId | undefined { const sessionParameter = parameters.find(parameter => parameter.name === 'sessionId' || parameter.wire === 'sessionId') @@ -90,6 +91,9 @@ function resolveSessionResource({ args, parameters }: TypertResourceResolveInput if (typeof value !== 'object' || value === null || Array.isArray(value)) continue const request = value as { readonly sessionId?: SessionId; readonly address?: SessionAddress } const address = request.address + if (request.sessionId !== undefined && address !== undefined) { + throw new Error('Session resource request must not contain both sessionId and address') + } return request.sessionId ?? (address === undefined ? undefined : address.kind === 'session' ? address.sessionId : address.childSessionId) } diff --git a/packages/api/session-controller/tests/remote-access.host.spec.ts b/packages/api/session-controller/tests/remote-access.host.spec.ts index b69a7063a5..5d17849476 100644 --- a/packages/api/session-controller/tests/remote-access.host.spec.ts +++ b/packages/api/session-controller/tests/remote-access.host.spec.ts @@ -86,8 +86,8 @@ async function harness(): Promise<{ await ctx.plugin(TypertRegistry) await mountAgentLoopTestDependencies(ctx) const loop = await mountAgentLoopTestHarness(ctx) - const shared = await loop.create(SHARED) - const hidden = await loop.create(PRIVATE) + const shared = await loop.create(SHARED, {}, { cwd: '/tmp' }) + const hidden = await loop.create(PRIVATE, {}, { cwd: '/tmp' }) const controller = await createSessionTestController(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const as = (principal: Principal, method: string): Context => { const invocation: RemoteInvocation = { @@ -115,20 +115,21 @@ describe('Session Controller access', () => { it('reads the Session resource of page and follow requests from the addressed Session', async () => { const { ctx, controller } = await harness() const resource = ctx.typert.resources.get('session') - if (resource === undefined || !('resolve' in resource)) throw new Error('Session resource resolver is missing') + if (resource?.resolve === undefined) throw new Error('Session resource resolver is missing') for (const method of ['page', 'follow']) { - const reader = remoteMethods(controller).find(marker => marker.method === method)?.resources?.['session'] - ?? ((request: unknown) => resource.resolve({ args: { request }, parameters: [{ name: 'request', wire: 'request' }] })) - expect(reader?.({ address: { kind: 'session', sessionId: SHARED } })).toBe(SHARED) - // A subagent address reads the child transcript; whether the parent's access covers it is the policy's rule. - expect(reader?.({ address: { kind: 'subagent', parentSessionId: SHARED, childSessionId: PRIVATE } })).toBe(PRIVATE) + const marker = remoteMethods(controller).find(marker => marker.method === method) + expect(marker).toBeDefined() + expect(marker?.resources?.['session']).toBeUndefined() } + const resolve = (request: unknown) => resource.resolve({ args: { request }, parameters: [{ name: 'request', wire: 'request' }] }) + expect(resolve({ address: { kind: 'session', sessionId: SHARED } })).toBe(SHARED) + expect(resolve({ address: { kind: 'subagent', parentSessionId: SHARED, childSessionId: PRIVATE } })).toBe(PRIVATE) }) it('resolves named Session IDs and first-object requests without treating unrelated strings as identities', async () => { const { ctx } = await harness() const resource = ctx.typert.resources.get('session') - if (resource === undefined || !('resolve' in resource)) throw new Error('Session resource resolver is missing') + if (resource?.resolve === undefined) throw new Error('Session resource resolver is missing') const resolve = (args: Readonly>, ...names: string[]) => resource.resolve({ args, parameters: names.map(name => ({ name, wire: name })), }) @@ -146,6 +147,40 @@ describe('Session Controller access', () => { expect(resource.resolve({ args: { target: SHARED }, parameters: [{ name: 'sessionId', wire: 'target' }] })).toBe(SHARED) }) + it.each(['page', 'follow'])('authorizes %s by Address and rejects coexisting Session IDs through SRC dispatch', async (method) => { + const { ctx, shared, hidden } = await harness() + shared.inbox.append('next-turn', message('shared history')) + hidden.inbox.append('next-turn', message('private history')) + await ctx.plugin(TypertGatewayService) + guestPolicy(ctx, { + role: { name: 'test:history-reader', resource: 'session', actions: ['session/page', 'session/follow'] }, + resource: { kind: 'session', id: SHARED }, + }) + const abort = new AbortController() + const peer: PeerScope = { id: 'history-access-peer' as PeerId, ctx, principal: GUEST, interactions: 'answer', signal: abort.signal, dispose: async () => {} } + const invoke = async (request: Record): Promise => { + const call = { peer, namespace: 'session', method, args: { request }, signal: abort.signal } + if (method === 'page') return ctx.typertGateway.invoke(call) + const stream = (await ctx.typertGateway.stream(call))[Symbol.asyncIterator]() + try { + return (await stream.next()).value + } finally { + await stream.return?.() + } + } + const request = (sessionId: SessionId) => ({ address: { kind: 'session', sessionId }, throughSeq: 0 }) + try { + await expect(invoke(request(SHARED))).resolves.toBeDefined() + await expect(invoke(request(PRIVATE))).rejects.toMatchObject({ code: 'remote-access/denied', details: { reason: 'no-allow' } }) + for (const target of [SHARED, PRIVATE]) { + await expect(invoke({ ...request(target), sessionId: SHARED })) + .rejects.toMatchObject({ code: 'remote-access/denied', details: { reason: 'resource-invalid' } }) + } + } finally { + abort.abort() + } + }) + it('lets a global role call session/list and lists only the Sessions a Session role admits', async () => { const { ctx, as } = await harness() await ctx.plugin(TypertGatewayService) diff --git a/packages/typert/protocol/src/types.ts b/packages/typert/protocol/src/types.ts index a9b362cfb8..2bf63755f7 100644 --- a/packages/typert/protocol/src/types.ts +++ b/packages/typert/protocol/src/types.ts @@ -886,24 +886,26 @@ export interface TypertResourceResolveInput { /** Default identity extraction for one resource kind. */ export type TypertResourceDeclaration = - | { readonly field: string } + | { readonly field: string; readonly resolve?: never } | { - /** Called once before lookups resolve. Undefined means no matching resource. */ + readonly field?: never + /** Called once before lookups resolve. Returns one identity, or undefined when absent; throws to reject the resource. */ resolve(input: TypertResourceResolveInput): string | undefined } /** One registered resource kind with its default identity extraction. */ export type TypertResourceEntry = TypertResourceDeclaration & { + /** Resource kind contributed by its owning domain. */ readonly kind: string } -/** Runtime registry of resource kinds' default wire fields. */ +/** Runtime registry of resource kinds' default identity extraction. */ export interface TypertResourceRegistry { /** - * Register one kind's default wire field for the calling fiber. Only the + * Register one kind's default identity extraction for the calling fiber. Only the * domain that owns the kind registers it. * @param kind - merge-declared resource kind. - * @param declaration - the kind's default wire field. + * @param declaration - either a default wire field or a resolver, never both. * @returns disposer withdrawing the exact registration. * @throws Error when the kind is already registered or is `global`, the built-in resource kind. */ @@ -914,7 +916,7 @@ export interface TypertResourceRegistry { * @returns the live declaration, or `undefined` when unregistered. */ get(kind: string): TypertResourceDeclaration | undefined - /** @returns a registration-order snapshot of registered kinds with their fields. */ + /** @returns a registration-order snapshot of registered kinds with their extraction declarations. */ entries(): readonly TypertResourceEntry[] /** * Observe later resource-kind changes. diff --git a/packages/typert/registry/README.i18n.yaml b/packages/typert/registry/README.i18n.yaml index 0f49341a19..ee88fadfc3 100644 --- a/packages/typert/registry/README.i18n.yaml +++ b/packages/typert/registry/README.i18n.yaml @@ -27,8 +27,8 @@ en: 9ca425515904d5c9 zh: b251226ecfa32002 /deepseek-ai-dsh-typert-registry/use-this-package/lookup-and-context-providers: - en: 937dfd14d9e18a5c - zh: 6cf6aa352c8825c6 + en: a1ba884253d02d96 + zh: 90165b44da9473f0 /deepseek-ai-dsh-typert-registry/understand-the-implementation: en: 1a78dfbae50a8b0d zh: dc10b4b3f3d832ed @@ -36,8 +36,8 @@ en: 4d50f3efeb45eaa4 zh: ec3575d29d5a040b /deepseek-ai-dsh-typert-registry/understand-the-implementation/sub-registries: - en: d6007eef4d33f3a4 - zh: 499ff578453d5cc0 + en: 6e38f43ea3a1d5f6 + zh: 5ff82d78d6afbab5 /deepseek-ai-dsh-typert-registry/understand-the-implementation/identity-and-validation: en: 4764fc33a7e16e60 zh: 1ddf03cbe16197da diff --git a/packages/typert/registry/README.md b/packages/typert/registry/README.md index fc6aebaa52..0e2822a1c9 100644 --- a/packages/typert/registry/README.md +++ b/packages/typert/registry/README.md @@ -51,7 +51,7 @@ Harness offline integrations register an ordinary `TYPERT_REMOTE` contribution w Remote calls resolve Host objects and scoped Contexts through `ctx.typert.lookups` and `ctx.typert.contexts`. `registerHost()` installs the Host wire declaration and its wire-to-Context resolver, while `configureHost()` replaces only that resolver. `registerClient()` installs the bidirectional Client adapter for the same merge-declared kind. Host-to-Client event sources carry their domain identity explicitly instead of projecting it back from a Context. -`ctx.typert.resources` holds the default wire field of each resource kind declared in `TypertResourceMap`. The domain that owns a kind registers it with `register(kind, { field })` as a Cordis effect of the calling fiber; a second registration of one kind throws, and so does `global`, the built-in identity-less resource kind. `get(kind)` reads one declaration, and `entries()` lists every registered `{ kind, field }` in registration order. A lookup provider and a Host Context adapter may name the resource kind of their wire identity through `resource`, and a configured resolver keeps it. The Gateway reads all three when it resolves the resources of a non-owner call. +`ctx.typert.resources` holds the default identity extraction of each resource kind declared in `TypertResourceMap`. The domain that owns a kind registers either `{ field }` or `{ resolve }`, never both, as a Cordis effect of the calling fiber; a second registration of one kind throws, and so does `global`, the built-in identity-less resource kind. `get(kind)` reads one declaration, and `entries()` lists each `kind` with its field or resolver declaration in registration order. A lookup provider and a Host Context adapter may name the resource kind of their wire identity through `resource`, and a configured resolver keeps it. The Gateway reads all three when it resolves the resources of a non-owner call. ----- @@ -73,7 +73,7 @@ The registry is built on one principle: a contribution is one atomic, fiber-owne - `ctx.typert.remotes` — consumer-selected contributions mounted in the calling fiber. - `ctx.typert.lookups` — lookup providers plus composition-owned resolver overrides per key. - `ctx.typert.contexts` — Host Context providers and Client Context binders per scoped key. -- `ctx.typert.resources` — the default wire field per resource kind. +- `ctx.typert.resources` — the default field or resolver per resource kind. Each sub-registry publishes `TypertRegistryChange` events to subscribed listeners; a throwing listener is logged and does not stop later listeners. diff --git a/packages/typert/registry/README.zh.md b/packages/typert/registry/README.zh.md index c558d14460..c53ca04273 100644 --- a/packages/typert/registry/README.zh.md +++ b/packages/typert/registry/README.zh.md @@ -51,7 +51,7 @@ Harness 离线集成在各自的 profile Context 中通过 `ctx.typert.remotes.r Remote 调用通过 `ctx.typert.lookups` 与 `ctx.typert.contexts` 解析 Host 对象与作用域 Context。`registerHost()` 安装 Host wire 声明及其 wire 到 Context 的 resolver,`configureHost()` 只替换该 resolver。`registerClient()` 为同一个 merge-declared kind 安装双向 Client adapter。Host 到 Client 的事件源显式携带领域 identity,而不从 Context 反向投影。 -`ctx.typert.resources` 保存 `TypertResourceMap` 中每个资源种类的默认 wire 字段。拥有该种类的领域以 `register(kind, { field })` 登记,登记是调用方 fiber 的 Cordis effect;同一种类重复登记即抛错,登记内置的无身份资源种类 `global` 同样抛错。`get(kind)` 读取一个种类的声明,`entries()` 按登记顺序列出全部已登记的 `{ kind, field }`。lookup provider 与 Host Context adapter 可经 `resource` 声明其 wire 身份所属的资源种类,配置的 resolver 保留该声明。Gateway 解析非 owner 调用的资源时读取这三者。 +`ctx.typert.resources` 保存 `TypertResourceMap` 中每个资源种类的默认身份提取规则。拥有该种类的领域登记 `{ field }` 或 `{ resolve }`,不能同时提供两者,登记是调用方 fiber 的 Cordis effect;同一种类重复登记即抛错,登记内置的无身份资源种类 `global` 同样抛错。`get(kind)` 读取一个种类的声明,`entries()` 按登记顺序列出每个 `kind` 及其字段或 resolver 声明。lookup provider 与 Host Context adapter 可经 `resource` 声明其 wire 身份所属的资源种类,配置的 resolver 保留该声明。Gateway 解析非 owner 调用的资源时读取这三者。 ----- @@ -73,7 +73,7 @@ Remote 调用通过 `ctx.typert.lookups` 与 `ctx.typert.contexts` 解析 Host - `ctx.typert.remotes`——在调用方 fiber 中挂载的、由消费方选中的贡献。 - `ctx.typert.lookups`——lookup 提供方,以及按键配置的组合方解析器覆盖。 - `ctx.typert.contexts`——按作用域键配置的 Host Context 提供方与 Client Context 绑定器。 -- `ctx.typert.resources`——每个资源种类的默认 wire 字段。 +- `ctx.typert.resources`——每个资源种类的默认字段或 resolver。 每个子注册表都会向已订阅的监听器发布 `TypertRegistryChange` 事件;抛异常的监听器会被记录日志,且不会阻止后续监听器。 diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index 15a5e46273..9007b9eee6 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -471,7 +471,7 @@ class ResourceStore { if (kind === GLOBAL_RESOURCE_KIND) { throw new Error(`typert: resource kind "${GLOBAL_RESOURCE_KIND}" is reserved for the built-in global resource`) } - if ('field' in declaration) validateWireName('resource field', declaration.field) + if (declaration.field !== undefined) validateWireName('resource field', declaration.field) if (this.declarations.has(kind)) throw new Error(`typert: resource kind "${kind}" is already registered`) const entry: ProviderEntry = { provider: { ...declaration }, owner: {} } const { declarations, changes } = this diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 38a2c06f12..966c730767 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -449,6 +449,28 @@ describe('TypertRegistry', () => { expect(ctx.typert.resources.get('registryFixture')).toBeUndefined() }) + it('preserves a default resolver in get and entries and withdraws it with its fiber', async () => { + const ctx = await makeCtx() + const resolve = () => 'fixture-id' + const changes: TypertRegistryChange[] = [] + ctx.typert.resources.subscribe(change => { changes.push(change) }) + const fiber = ctx.plugin({ + inject: ['typert'], + apply: (pluginCtx: Context) => { pluginCtx.typert.resources.register('registryFixture', { resolve }) }, + }) + await fiber + expect(ctx.typert.resources.get('registryFixture')).toEqual({ resolve }) + expect(ctx.typert.resources.entries()).toEqual([{ kind: 'registryFixture', resolve }]) + expect(() => ctx.typert.resources.register('registryFixture', { resolve })).toThrow('already registered') + await fiber.dispose() + expect(ctx.typert.resources.get('registryFixture')).toBeUndefined() + expect(ctx.typert.resources.entries()).toEqual([]) + expect(changes).toEqual([ + { kind: 'resource', key: 'registryFixture' }, + { kind: 'resource', key: 'registryFixture' }, + ]) + }) + it('reserves the built-in global resource kind', async () => { const ctx = await makeCtx() const changes: TypertRegistryChange[] = [] From 3c4a79cc1bf9128d42582a7a8b8e33fb0da02045 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:22:07 +0800 Subject: [PATCH 40/46] chore(session): remove stray blank lines --- packages/api/session-controller/src/index.ts | 2 -- packages/api/session-controller/tests/agent.host.spec.ts | 2 -- 2 files changed, 4 deletions(-) diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index c053b1573a..c234725c25 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -599,8 +599,6 @@ export class SessionController extends TypertRemoteService { } } } - - } export default SessionController diff --git a/packages/api/session-controller/tests/agent.host.spec.ts b/packages/api/session-controller/tests/agent.host.spec.ts index 915bfff20f..aea96d5e04 100644 --- a/packages/api/session-controller/tests/agent.host.spec.ts +++ b/packages/api/session-controller/tests/agent.host.spec.ts @@ -81,8 +81,6 @@ describe('ApiSession identity failures', () => { expect(new ApiSessionCwdConflict(SessionId('wrong-cwd'), '/wanted', '/existing').message) .toContain('belongs to "/existing"') }) - - }) describe('ApiSession Agent lookup and recovery', () => { From 0b8eabaf665b4a0aa3590d8eaf045963f27dfbb1 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 23 Oct 2026 16:27:34 +0800 Subject: [PATCH 41/46] fix(dockkit): record the strip offset when the strip unmounts Reading scrollLeft in the layout cleanup also captures positions that fire no scroll event, such as a restore the browser clamps to the current offset. Refs #7049 --- packages/client/ui-dockkit/README.i18n.yaml | 4 +- packages/client/ui-dockkit/README.md | 2 +- packages/client/ui-dockkit/README.zh.md | 2 +- .../ui-dockkit/src/components/TabPanel.tsx | 15 ++--- .../src/components/dockkit.module.css | 3 +- .../tests/tab-layout.client.spec.tsx | 57 +++++++++---------- 6 files changed, 40 insertions(+), 43 deletions(-) diff --git a/packages/client/ui-dockkit/README.i18n.yaml b/packages/client/ui-dockkit/README.i18n.yaml index 4863e9497a..3dafc30f52 100644 --- a/packages/client/ui-dockkit/README.i18n.yaml +++ b/packages/client/ui-dockkit/README.i18n.yaml @@ -21,8 +21,8 @@ en: c4ba08038bfa6f30 zh: 95db30618b52babe /deepseek-ai-dsh-client-ui-dockkit/interaction-rules-worth-keeping: - en: 6ebe16d5610bb8f8 - zh: 8442e617bc1f52b2 + en: cf2d00ce2b7d5719 + zh: 5510420f8872e23a /deepseek-ai-dsh-client-ui-dockkit/build-shape: en: 21216ba5798d7f5f zh: ac505fdcd5c114d4 diff --git a/packages/client/ui-dockkit/README.md b/packages/client/ui-dockkit/README.md index 813588feed..bc29b54c08 100644 --- a/packages/client/ui-dockkit/README.md +++ b/packages/client/ui-dockkit/README.md @@ -74,7 +74,7 @@ The split control accepts localized tooltip text, separate effective keys throug These are not stylistic; each one fixes a defect found in a real browser. - **Capture the pointer** when a gesture starts. Without it any scroll container the pointer crosses can claim the gesture, which the browser reports as a cancelled pointer and an abandoned drag. Capture is hardening — the window listeners carry the gesture either way, so an environment without the API still works. -- **The chips give way; the strip's end controls never do.** The chip box is the strip's one shrinking part (`flex: 0 1 auto; min-width: 0; overflow-x: auto`): chips shrink down to an 80px floor and then scroll on the wheel with no scrollbar drawn, and the box fades its chips out over 24px at each side that hides some (`data-dockkit-strip-scroll`, written from the box's scroll reading after each commit, scroll, and resize). Whenever the active tab or the row of chips changes, the box scrolls so the active chip stands clear of the fade band; a chip already in view moves nothing. Where `DockLayout` draws a new strip for each newly selected tab, that strip first jumps to the offset the pane's previous strip recorded, so only the remaining distance animates; a strip with no recorded offset jumps straight to its first position instead of scrolling from the row's start. A chip's title is never ellipsized: `TabTitle` reads its text against its box and sets `data-dockkit-tab-clipped` while the text is wider, which fades the text out over its last 16px. A chip's close control shows while the chip is active, hovered, or holds focus, over the title's last 14px, which fade under it, so the chip is the same width either way. The two slots beside the active chip draw no hairline, so the filled capsule stands between bare chips. The add, split, and chrome controls are `flex: none`, so they keep their width and place in any pane at least as wide as they are (about 130px with the chrome, 72px without). The surface's `min-width: 0` and the pane's `overflow: hidden` stop a body's longest unwrapped line from widening the pane past its box, which is what carried the controls and the body's scrollbar off-screen. +- **The chips give way; the strip's end controls never do.** The chip box is the strip's one shrinking part (`flex: 0 1 auto; min-width: 0; overflow-x: auto`): chips shrink down to an 80px floor and then scroll on the wheel with no scrollbar drawn, and the box fades its chips out over 24px at each side that hides some (`data-dockkit-strip-scroll`, written from the box's scroll reading after each commit, scroll, and resize). Whenever the active tab or the row of chips changes, the box scrolls so the active chip stands clear of the fade band; a chip already in view moves nothing. Where `DockLayout` draws a new strip for each newly selected tab, that strip first jumps to the offset the pane's previous strip recorded, so only the remaining distance animates; a strip with no recorded offset jumps straight to the active chip instead of scrolling from the row's start. A chip's title is never ellipsized: `TabTitle` reads its text against its box and sets `data-dockkit-tab-clipped` while the text is wider, which fades the text out over its last 16px. A chip's close control shows while the chip is active, hovered, or holds focus, over the title's last 14px, which fade under it, so the chip is the same width either way. The two slots beside the active chip draw no hairline, so the filled capsule stands between bare chips. The add, split, and chrome controls are `flex: none`, so they keep their width and place in any pane at least as wide as they are (about 130px with the chrome, 72px without). The surface's `min-width: 0` and the pane's `overflow: hidden` stop a body's longest unwrapped line from widening the pane past its box, which is what carried the controls and the body's scrollbar off-screen. - **The chip box scrolls, but never claims a gesture.** A horizontal scroller would take press-and-move for itself and cancel the pointer; the box, the chips, and the strip set `touch-action: none` and the gesture captures the pointer, so a press-and-move on a chip is a drag and only the wheel scrolls the box. - **A split needs room for two working halves.** A pane splits into equal halves, so each half must hold what cannot shrink: the strip's fixed part — measured as the strip's width minus the chip box and the fill, which is the padding, the gaps, and every control that pane draws (its own chrome included, so the top-right pane asks more) — plus one chip at its minimum — `.tab` declares `min-width: 80px` on a content-box, so its footprint is 80px plus 10px + 10px of padding, 100px, read from a rendered chip's computed style (the stylesheet figure when none can be read); the divider between the halves takes its rendered thickness (0 — its hairline paints over the seam without taking layout room, so a body's own rules run unbroken past it). A column split, which only an edge drop makes, needs each half to hold the strip (34px) plus a 48px body: one 13px secondary line at 1.6 line-height inside 12px of the body's own insets — the pane body itself is unpadded, so a tab's body reaches the strip's bottom edge and the pane's edges and draws its own. `halvesFit` in `geometry.ts` is the arithmetic; `measure.ts` reads the rectangles after every commit and whenever the surface resizes, because the layout state carries fractions, never pixels, and the engine's planners stay that way. A pane without room keeps its split control, disabled with `splitPaneNarrow` (hidden instead under `hideSplitWhenBlocked`), and offers no edge drop zone for that axis (the release is then not a move). Under `hideSplitWhenBlocked` the split control's own footprint — its box plus the strip's gap — is left out of the fixed part: hiding the control sheds exactly that footprint from the strip, so a reading that counted it would flip with the control's visibility and re-render forever; leaving it out is also what the half being asked about would carry, since a half too narrow to split hides its own control. A pane the user narrows afterwards — a divider or the embedder's column dragged — keeps its size: the rule only decides its next split. - **Focus lands on click, not on press.** A state change between `pointerdown` and the first `pointermove` rebuilds the pressed subtree, and a replaced element cancels the pointer. It also keeps a drag from recording a redundant focus operation first. Clicks on the chips, the strip's controls, and the embedder's chrome stop at the strip: the intent each reports already decides the active pane, or is the embedder's own, so the pane's click-to-focus records nothing extra. A floating panel's grip and corner report through their gesture the same way — a press released in place is a click that raises the panel, and a drag records only the move or resize, whose operation raises it — while a press on the panel's body raises it directly. A click on the pane that is active already, a click or key on that pane's selected chip, or a press on the panel that is active and on top already, changes nothing and records nothing. diff --git a/packages/client/ui-dockkit/README.zh.md b/packages/client/ui-dockkit/README.zh.md index 3a23f282ff..15c5d332d9 100644 --- a/packages/client/ui-dockkit/README.zh.md +++ b/packages/client/ui-dockkit/README.zh.md @@ -74,7 +74,7 @@ Sidebar 使用的 `DockLayout` 在选中项变化、跨格移动与浮动期间 这些不是风格偏好;每一条都修复了在真实浏览器里发现的缺陷。 - **手势开始时捕获指针。** 不捕获的话,指针经过的任何滚动容器都可能接管手势,浏览器会将其报告为指针取消和拖动中止。捕获是加固——无论如何都由 window 监听器承载手势,所以没有该 API 的环境照样可用。 -- **chip 让位;tab 条末端的控件永不让位。** chip 盒是 tab 条里唯一会收缩的部分(`flex: 0 1 auto; min-width: 0; overflow-x: auto`):chip 先缩到 80px 下限,再在盒内随滚轮横向滚动、不画滚动条,且盒在每个藏有 chip 的一侧把 chip 在 24px 内渐隐(`data-dockkit-strip-scroll`,在每次提交、滚动与尺寸变化后由盒的滚动读数写入)。每当活动 tab 或 chip 的排列变化,盒会滚动到让活动 chip 避开渐隐带;已在视野内的 chip 不动。`DockLayout` 为每个新选中的 tab 画出新 tab 条时,新 tab 条先跳到该格上一条 tab 条记录的偏移,只让剩余距离带动画;没有记录偏移的 tab 条直接跳到它的初始位置,而不是从排列起点滚动过去。chip 的标题从不加省略号:`TabTitle` 拿文字宽度对照它的盒子,文字更宽时置 `data-dockkit-tab-clipped`,让文字在末端 16px 内渐隐。chip 的关闭控件在 chip 活动、悬停或持有焦点时显示,压在标题末端 14px 之上、标题在其下渐隐,因此 chip 宽度两种情况下都一样。活动 chip 两侧的槽不画细线,让填色胶囊立在裸 chip 之间。添加、分栏与 chrome 控件都是 `flex: none`,因此在任何不窄于它们自身的格里(带 chrome 约 130px,不带约 72px)都保持宽度与位置。停靠面的 `min-width: 0` 与格的 `overflow: hidden` 阻止正文里最长的不换行行把格撑出自己的盒子——正是那种情况把控件和正文滚动条推到了屏幕外。 +- **chip 让位;tab 条末端的控件永不让位。** chip 盒是 tab 条里唯一会收缩的部分(`flex: 0 1 auto; min-width: 0; overflow-x: auto`):chip 先缩到 80px 下限,再在盒内随滚轮横向滚动、不画滚动条,且盒在每个藏有 chip 的一侧把 chip 在 24px 内渐隐(`data-dockkit-strip-scroll`,在每次提交、滚动与尺寸变化后由盒的滚动读数写入)。每当活动 tab 或 chip 的排列变化,盒会滚动到让活动 chip 避开渐隐带;已在视野内的 chip 不动。`DockLayout` 为每个新选中的 tab 画出新 tab 条时,新 tab 条先跳到该格上一条 tab 条记录的偏移,只让剩余距离带动画;没有记录偏移的 tab 条直接跳到活动 chip,而不是从排列起点滚动过去。chip 的标题从不加省略号:`TabTitle` 拿文字宽度对照它的盒子,文字更宽时置 `data-dockkit-tab-clipped`,让文字在末端 16px 内渐隐。chip 的关闭控件在 chip 活动、悬停或持有焦点时显示,压在标题末端 14px 之上、标题在其下渐隐,因此 chip 宽度两种情况下都一样。活动 chip 两侧的槽不画细线,让填色胶囊立在裸 chip 之间。添加、分栏与 chrome 控件都是 `flex: none`,因此在任何不窄于它们自身的格里(带 chrome 约 130px,不带约 72px)都保持宽度与位置。停靠面的 `min-width: 0` 与格的 `overflow: hidden` 阻止正文里最长的不换行行把格撑出自己的盒子——正是那种情况把控件和正文滚动条推到了屏幕外。 - **chip 盒会滚动,但绝不认领手势。** 横向滚动容器会把按下并移动的手势据为己有并取消指针;盒、chip 与 tab 条都设 `touch-action: none`,手势又捕获了指针,所以在 chip 上按下并移动是拖动,只有滚轮滚动盒子。 - **分栏需要给两个可用的半格留出空间。** 格被等分成两半,因此每一半都必须容得下不可收缩的部分:tab 条的固定部分——按 tab 条宽减去 chip 盒与填充条测得,即内边距、间隙以及该格绘制的每个控件(含它自己的 chrome,所以右上格要求更多)——加上一枚最小尺寸的 chip——`.tab` 在 content-box 上声明 `min-width: 80px`,所以它的足印是 80px 加 10px + 10px 内边距,即 100px,从已渲染 chip 的计算样式读取(读不到时用样式表数值);两半之间的分隔条取其渲染厚度(0——它的细线画在接缝上、不占布局空间,因此正文自己画的分隔线能不断线地穿过接缝)。纵向分栏只由边缘落下产生,它要求每一半容得下 tab 条(34px)加 48px 正文:正文自留的 12px 内边距内一行 13px、行高 1.6 的次级文字——格的正文容器本身没有内边距,tab 的正文直接贴到 tab 条底边和格的边缘,由自己留白。`geometry.ts` 里的 `halvesFit` 是算术;`measure.ts` 在每次提交后与停靠面尺寸变化时读取矩形,因为布局状态只携带比例、从不携带像素,引擎的 planner 也保持如此。没有空间的格保留分栏控件,以 `splitPaneNarrow` 禁用(开启 `hideSplitWhenBlocked` 时改为隐藏),并且在该轴上不提供边缘落区(松手就不是移动)。开启 `hideSplitWhenBlocked` 时,分栏控件自己的占位——它的盒子加 tab 条的间隙——不计入固定部分:隐藏控件让 tab 条卸下的恰是这份占位,把它算进去的读数会随控件的可见性来回翻转、无限重渲染;不计入也正是被询问的那一半会承载的量,因为窄到无法分栏的一半会隐藏自己的控件。用户随后拖窄的格——通过拖动分隔条或嵌入方的列——会保持原尺寸:规则只决定它的下一次分栏。 - **焦点落在 click 而不是按下。** 在 `pointerdown` 与第一次 `pointermove` 之间的状态变化会重建被按下的子树,而被替换的元素会取消指针。这也避免拖动先记录一条多余的焦点操作。chip、标签条各控件以及嵌入方 chrome 上的 click 都止于标签条:它们各自上报的意图已决定了活动格,或本就是嵌入方自己的事,所以格自身的点击聚焦不再多记一条。浮动面板的抓手与角柄同样通过手势上报——原地松开的按下是一次 click,抬起面板;真正的拖动只记录移动或缩放,由该操作自己抬起面板——而按在面板主体上则直接抬起它。点击本已活动的格、点击或按键选中该格本已选中的 chip,或按下本已活动且在最上层的面板,什么都不改变,也什么都不记录。 diff --git a/packages/client/ui-dockkit/src/components/TabPanel.tsx b/packages/client/ui-dockkit/src/components/TabPanel.tsx index 0bdf80640b..c0e3bcd888 100644 --- a/packages/client/ui-dockkit/src/components/TabPanel.tsx +++ b/packages/client/ui-dockkit/src/components/TabPanel.tsx @@ -194,9 +194,9 @@ function jumpTo(element: HTMLElement, left: number): void { * A box newly mounted, or newly showing another pane, first jumps to that * pane's offset in `offsets`, so a strip that replaces another for the same * pane continues from where that one stood and glides only the remaining - * distance. With no recorded offset the - * first placement jumps rather than glides from the row's start. The box - * records its offset in `offsets` on every scroll. + * distance. With no recorded offset the first placement jumps rather than + * glides from the row's start. The box records its offset in `offsets` when + * it unmounts or leaves its pane. */ function useActiveChipInView( box: RefObject, @@ -213,9 +213,8 @@ function useActiveChipInView( const element = box.current /* v8 ignore next -- the box is rendered unconditionally with the strip. */ if (element === null) return undefined - const record = (): void => { offsets.set(paneId, element.scrollLeft) } - element.addEventListener('scroll', record, { passive: true }) - return () => { element.removeEventListener('scroll', record) } + // Layout cleanup runs before React detaches the box, so it still reads the live offset. + return () => { offsets.set(paneId, element.scrollLeft) } }, [box, paneId, offsets]) useLayoutEffect(() => { const element = box.current @@ -252,7 +251,9 @@ function splitBlockedTitle(labels: PaneCallbacks['labels'], block: SplitBlock): interface TabStripProps extends TabPanelProps { /** * Chip-box offsets by pane, shared by the strips that replace one another - * as a pane's selected tab changes; omitted for a strip that stays mounted. + * as a pane's selected tab changes. Keys are valid within one layout only, + * and entries outlive their panes. Omitted where no strip continues from + * another's position. */ readonly offsets?: Map } diff --git a/packages/client/ui-dockkit/src/components/dockkit.module.css b/packages/client/ui-dockkit/src/components/dockkit.module.css index e57adb57cf..9aec2d8faa 100644 --- a/packages/client/ui-dockkit/src/components/dockkit.module.css +++ b/packages/client/ui-dockkit/src/components/dockkit.module.css @@ -263,7 +263,8 @@ min-width: 0; overflow-x: auto; overflow-y: hidden; - /* The scroll to a newly active chip glides rather than jumps. */ + /* The scroll to a newly active chip glides; `jumpTo` in TabPanel.tsx + overrides this for the placements that must not animate. */ scroll-behavior: smooth; scrollbar-width: none; touch-action: none; diff --git a/packages/client/ui-dockkit/tests/tab-layout.client.spec.tsx b/packages/client/ui-dockkit/tests/tab-layout.client.spec.tsx index d03e9c367d..7924776768 100644 --- a/packages/client/ui-dockkit/tests/tab-layout.client.spec.tsx +++ b/packages/client/ui-dockkit/tests/tab-layout.client.spec.tsx @@ -271,19 +271,32 @@ it('keeps a superseding gesture marker when an older follower detaches', () => { expect(element.dataset.dockkitPointer).toBeUndefined() }) -it('continues a selected tab\'s strip from its pane\'s last offset and jumps on a first placement', () => { - let scrollLeft = 0 - // Each assignment with the inline `scroll-behavior` it ran under: `auto` jumps, empty leaves the stylesheet's glide. +/** + * Give each element its own `scrollLeft`, starting at 0, and log every + * assignment with the inline `scroll-behavior` it ran under: `auto` jumps, + * empty leaves the stylesheet's glide. + */ +function stubScrollLeft() { + const offsets = new WeakMap() const writes: (readonly [number, string])[] = [] const descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollLeft') Object.defineProperty(Element.prototype, 'scrollLeft', { configurable: true, - get() { return scrollLeft }, + get(this: Element) { return offsets.get(this) ?? 0 }, set(this: HTMLElement, value: number) { - scrollLeft = value + offsets.set(this, value) writes.push([value, this.style.scrollBehavior]) }, }) + const restore = (): void => { + if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, 'scrollLeft') + else Object.defineProperty(Element.prototype, 'scrollLeft', descriptor) + } + return { offsets, writes, restore } +} + +it('continues a selected tab\'s strip from its pane\'s last offset and jumps on a first placement', () => { + const { writes, restore } = stubScrollLeft() const rect = (left: number, width: number): DOMRect => ({ left, right: left + width, top: 0, bottom: 28, x: left, y: 0, width, height: 28, toJSON: () => ({}), }) @@ -301,39 +314,23 @@ it('continues a selected tab\'s strip from its pane\'s last offset and jumps on }) try { const h = mounted(controller) - const scrolled = (): void => { fireEvent.scroll(h.node('[data-dockkit-strip-tabs]')) } // The first strip has no recorded offset: it jumps to the chip. expect(writes).toEqual([[164, 'auto']]) - scrolled() - // The seeded tab's strip starts where the previous one stood, where its chip is in view. - scrollLeft = 0 + // The seeded tab's new strip starts where the previous one stood, where its chip is in view. fireEvent.click(h.node(`[data-dockkit-tab="${seeded}"]`)) expect(writes.slice(1)).toEqual([[164, 'auto']]) - scrolled() // Back on the opened tab, now 30 past the left edge: restored, then glided the rest. openedAt = -30 - scrollLeft = 0 fireEvent.click(h.node(`[data-dockkit-tab="${opened}"]`)) expect(writes.slice(2)).toEqual([[164, 'auto'], [164 - 30 - 24, '']]) } finally { - if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, 'scrollLeft') - else Object.defineProperty(Element.prototype, 'scrollLeft', descriptor) + restore() } }) it('places a selected tab\'s strip moved to another pane from that pane\'s last offset', () => { - // Offsets per chip box, with the inline `scroll-behavior` of each assignment. - const offsets = new WeakMap() - const writes: (readonly [string | undefined, number, string])[] = [] - const descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollLeft') - Object.defineProperty(Element.prototype, 'scrollLeft', { - configurable: true, - get(this: Element) { return offsets.get(this) ?? 0 }, - set(this: HTMLElement, value: number) { - offsets.set(this, value) - writes.push([this.dataset.dockkitStripTabs, value, this.style.scrollBehavior]) - }, - }) + // jsdom lays every chip and box out at 0x0, so only restores write offsets here. + const { offsets, writes, restore } = stubScrollLeft() try { const controller = seededController() controller.setExpanded(true) @@ -346,15 +343,13 @@ it('places a selected tab\'s strip moved to another pane from that pane\'s last if (split.kind !== 'split') throw new Error('expected a split') const target = getPane(state, split.children[1]!).id act(() => { controller.focusTab(moved); h.redraw() }) - const strip = h.node(`[data-dockkit-strip-tabs="${target}"]`) - offsets.set(strip, 70) - fireEvent.scroll(strip) + offsets.set(h.node(`[data-dockkit-strip-tabs="${target}"]`), 70) writes.length = 0 act(() => { controller.placeTab(moved, target, 0); h.redraw() }) expect(h.node(`[data-dockkit-strip-tabs="${target}"] [data-dockkit-tab="${moved}"]`).getAttribute('aria-selected')).toBe('true') - expect(writes).toEqual([[target, 70, 'auto']]) + // The moved strip takes the target pane's 70; the source pane's next strip restores the 0 the moved one left there. + expect([...writes].sort((x, y) => x[0] - y[0])).toEqual([[0, 'auto'], [70, 'auto']]) } finally { - if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, 'scrollLeft') - else Object.defineProperty(Element.prototype, 'scrollLeft', descriptor) + restore() } }) From 9c3e97676e36ad77f243e274bddde3acbb795666 Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:33:05 +0800 Subject: [PATCH 42/46] fix(ci): report successful runner network fixture exit --- apps/desktop/tests/windows-runner-network-process.ps1 | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/tests/windows-runner-network-process.ps1 b/apps/desktop/tests/windows-runner-network-process.ps1 index 3c908fd039..7b8fe0887b 100644 --- a/apps/desktop/tests/windows-runner-network-process.ps1 +++ b/apps/desktop/tests/windows-runner-network-process.ps1 @@ -137,3 +137,4 @@ exit 0 if ([IO.Path]::GetDirectoryName($root).TrimEnd('\') -ne [IO.Path]::GetTempPath().TrimEnd('\')) { throw 'Unsafe cleanup path' } Remove-Item -LiteralPath $root -Recurse -Force } +exit 0 From cb60c52cea57d1212d564a254d044fcacc3330b0 Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:48:11 +0800 Subject: [PATCH 43/46] docs(desktop): clarify shared packaging cache maintenance --- apps/desktop/packaging/README.i18n.yaml | 8 ++++---- apps/desktop/packaging/README.md | 4 ++-- apps/desktop/packaging/README.zh.md | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index e99477593a..a551f4826f 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -15,8 +15,8 @@ en: a6203dc2e690c7e1 zh: 07b516549b8e3328 /desktop-packaging/choose-a-build-path: - en: 447e66fb59b03fb1 - zh: a87d440407b4bde3 + en: 96d97c0c49b64723 + zh: d8b3fe9909c34e83 /desktop-packaging/release-versions: en: 3f38ab26ee195b39 zh: f00eddafa8f3b9ca @@ -54,5 +54,5 @@ en: eb35eb973b796643 zh: c1c52311632b7296 /desktop-packaging/dev-note: - en: b75c8458928b72b9 - zh: 003ba4e17b4b5269 + en: d33dc26a98f06e95 + zh: 76b8bdc433304b95 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index e226c61d11..6aef8f398c 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -32,7 +32,7 @@ Build Desktop installers locally or through Windows or macOS Actions, choose dep Run local commands from the repository root after installing locked dependencies. Use platform-specific commands for a local build, or platform Actions for the registered self-hosted runner. Choose test or production deployment independently from signed or unsigned mode. Read [Windows signing and runner operations](../windows-signing/README.md#trust-and-isolation) before any Windows signed build or shared-runner use. Installation is separate from packaging; [publication](../publishing/README.md) is a further explicit operation. -Set `DSH_DESKTOP_PACKAGE_CACHE_DIR` to an absolute build-cache directory to retain verified downloads and the pnpm content store across checkouts. Both packaging workflows use `runner.tool_cache/dsh-desktop-package-cache`; local commands default to `.desktop-build`. Runtime preparation still creates a fresh installation and lockfile, verifies stored package integrity, and copies files before signing. Cleanup removes temporary installations, not these caches. Operators manage cache disk usage; no automatic eviction runs. +Set `DSH_DESKTOP_PACKAGE_CACHE_DIR` to an absolute build-cache directory to retain verified downloads and the pnpm content store across checkouts. Both packaging workflows use `runner.tool_cache/dsh-desktop-package-cache`; local commands default to `.desktop-build`. Runtime preparation still creates a fresh installation and lockfile, verifies stored package integrity, and copies files before signing. Cleanup removes temporary installations, not these caches. Share writable caches only among trusted build jobs; integrity checks do not isolate untrusted writers. Clean caches only when no job is using them. Operators manage cache disk usage; no automatic eviction runs. @@ -299,4 +299,4 @@ Keep the generated installer, its version and packaging records together. Review ## Dev Note -None. +TODO: Evaluate disk-space monitoring, a maintenance entry point and automatic eviction if disk shortages, sustained cache growth or frequent manual cleanup occur. These are deferred work, not current capabilities; automated cleanup requires coordination with every cache user. diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index cf8edbc322..4c5aefc986 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -32,7 +32,7 @@ description: "在本地或 Windows/macOS Actions 中生成 Desktop 安装包 安装锁定依赖后,从仓库根目录运行本地命令。本地构建使用平台命令,已注册的自托管 runner 使用对应平台的 Actions。test/production 部署与 signed/unsigned 模式分别选择。任何 Windows 签名构建或共享 runner 使用前,先阅读 [Windows 签名与 runner 运维](../windows-signing/README.zh.md#trust-and-isolation)。安装与打包分开进行;[发布](../publishing/README.zh.md)还需要单独的显式操作。 -将 `DSH_DESKTOP_PACKAGE_CACHE_DIR` 设为绝对构建缓存目录,即可跨 checkout 保留经过校验的下载文件和 pnpm 内容存储。两个打包工作流使用 `runner.tool_cache/dsh-desktop-package-cache`;本地命令默认使用 `.desktop-build`。runtime 准备仍创建全新安装目录和锁文件、校验缓存包的完整性,并在签名前复制文件。清理只删除临时安装,不删除这些缓存。缓存磁盘用量由操作者管理,不自动淘汰。 +将 `DSH_DESKTOP_PACKAGE_CACHE_DIR` 设为绝对构建缓存目录,即可跨 checkout 保留经过校验的下载文件和 pnpm 内容存储。两个打包工作流使用 `runner.tool_cache/dsh-desktop-package-cache`;本地命令默认使用 `.desktop-build`。runtime 准备仍创建全新安装目录和锁文件、校验缓存包的完整性,并在签名前复制文件。清理只删除临时安装,不删除这些缓存。可写缓存仅供可信构建任务共享;完整性校验不能隔离不可信的写入者。仅在没有任务使用缓存时清理。缓存磁盘用量由操作者管理,不自动淘汰。 @@ -299,4 +299,4 @@ Windows 以不带版本后缀的产品名称显示已安装应用,版本保留 ## 开发备注 -无。 +TODO:出现磁盘空间不足、缓存持续增长或频繁人工清理时,评估空间监测、维护入口和自动淘汰。这些是延期工作,尚未实现;自动清理需要与所有使用缓存的任务协调。 From a4aab8ec611943040c07eacc82a9f26d5b90a8e7 Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:18:48 +0800 Subject: [PATCH 44/46] docs(desktop): consolidate cache lifecycle guidance --- apps/desktop/packaging/README.i18n.yaml | 12 ++++++------ apps/desktop/packaging/README.md | 6 +++--- apps/desktop/packaging/README.zh.md | 6 +++--- docs/development.i18n.yaml | 2 +- docs/development.zh.md | 2 +- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index a551f4826f..7a9ff55492 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -15,8 +15,8 @@ en: a6203dc2e690c7e1 zh: 07b516549b8e3328 /desktop-packaging/choose-a-build-path: - en: 96d97c0c49b64723 - zh: d8b3fe9909c34e83 + en: 0b35c7471c09b27a + zh: cc4a382b99fc3210 /desktop-packaging/release-versions: en: 3f38ab26ee195b39 zh: f00eddafa8f3b9ca @@ -48,11 +48,11 @@ en: 83e1075417039410 zh: 3088a7ae0a2cc3c5 /desktop-packaging/desktop-runtime-preparation: - en: 59783a64552d4464 - zh: d7876d51b4015578 + en: ab73b67d899e611d + zh: d77b0525c6b81e91 /desktop-packaging/installation-and-release-verification: en: eb35eb973b796643 zh: c1c52311632b7296 /desktop-packaging/dev-note: - en: d33dc26a98f06e95 - zh: 76b8bdc433304b95 + en: 691194744993dfaa + zh: e5798faf99546ff8 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index 6aef8f398c..14c1eac90b 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -32,7 +32,7 @@ Build Desktop installers locally or through Windows or macOS Actions, choose dep Run local commands from the repository root after installing locked dependencies. Use platform-specific commands for a local build, or platform Actions for the registered self-hosted runner. Choose test or production deployment independently from signed or unsigned mode. Read [Windows signing and runner operations](../windows-signing/README.md#trust-and-isolation) before any Windows signed build or shared-runner use. Installation is separate from packaging; [publication](../publishing/README.md) is a further explicit operation. -Set `DSH_DESKTOP_PACKAGE_CACHE_DIR` to an absolute build-cache directory to retain verified downloads and the pnpm content store across checkouts. Both packaging workflows use `runner.tool_cache/dsh-desktop-package-cache`; local commands default to `.desktop-build`. Runtime preparation still creates a fresh installation and lockfile, verifies stored package integrity, and copies files before signing. Cleanup removes temporary installations, not these caches. Share writable caches only among trusted build jobs; integrity checks do not isolate untrusted writers. Clean caches only when no job is using them. Operators manage cache disk usage; no automatic eviction runs. +Set `DSH_DESKTOP_PACKAGE_CACHE_DIR` to an absolute build-cache directory for reuse across checkouts; see [cache configuration and lifecycle](#desktop-runtime-preparation). @@ -275,7 +275,7 @@ NSIS removes its temporary extraction tree during installation, before the compl Windows prepares Electron and primary-runtime concurrently; macOS prepares them serially. CLI preparation and `versions.json` publication require both payloads to succeed. If either Windows payload fails, preparation waits for the other to settle before reporting failure and publishes neither CLI resources nor versions. Nested preparation timings overlap and must not be added together. -`DSH_DESKTOP_PACKAGE_CACHE_DIR` in the packaging process environment selects a nonempty absolute directory for shared `downloads` and `pnpm-store` caches; omission uses `.desktop-build`. Temporary installation projects, lockfiles and configuration remain separate per run. Runtime installation verifies store integrity and copies package files so later signing cannot modify cached bytes; cleanup retains these caches. Windows Actions uses `runner.tool_cache/dsh-desktop-package-cache`, while repository dependency installation uses a separate `.pnpm-store` at the workspace drive root. Both installation logs identify their store and report package reuse. A fresh runtime lockfile still resolves dependencies on every run. +`DSH_DESKTOP_PACKAGE_CACHE_DIR` in the packaging process environment selects a nonempty absolute directory for shared `downloads` and `pnpm-store` caches; omission uses `.desktop-build`. Temporary installation projects, lockfiles and configuration remain separate per run. Runtime installation verifies store integrity and copies package files so later signing cannot modify cached bytes; cleanup retains these caches. Both packaging workflows use `runner.tool_cache/dsh-desktop-package-cache`; Windows repository dependency installation uses a separate `.pnpm-store` at the workspace drive root. Both installation logs identify their store and report package reuse. A fresh runtime lockfile still resolves dependencies on every run. Share writable caches only among trusted build jobs; integrity checks do not isolate untrusted writers. Clean caches only when no job is using them. Operators manage cache disk usage; no automatic eviction runs. Desktop [patch policy](../scripts/runtime-patch-policy.ts) explicitly classifies every workspace patch as shared, workspace-only, or runtime-only. Shared entries reuse the root patch bytes and verify their hash against the root lockfile; runtime-only entries use separate repository files without changing workspace installation. Workspace-only patches belong to build tools or dependencies already embedded in client bundles. Unclassified or stale entries, missing files, changed hashes, incompatible resolved versions, and workspace-only packages entering the runtime stop packaging. The [preparation script](../scripts/prepare-runtime-patches.ts) writes only to the temporary project; pnpm applies the selected patches and rejects application failures. @@ -299,4 +299,4 @@ Keep the generated installer, its version and packaging records together. Review ## Dev Note -TODO: Evaluate disk-space monitoring, a maintenance entry point and automatic eviction if disk shortages, sustained cache growth or frequent manual cleanup occur. These are deferred work, not current capabilities; automated cleanup requires coordination with every cache user. +XXX: Evaluate disk-space monitoring, a maintenance entry point and automatic eviction if disk shortages, sustained cache growth or frequent manual cleanup occur. Any automated cleanup would need coordination with every cache user. diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index 4c5aefc986..0f059c74a4 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -32,7 +32,7 @@ description: "在本地或 Windows/macOS Actions 中生成 Desktop 安装包 安装锁定依赖后,从仓库根目录运行本地命令。本地构建使用平台命令,已注册的自托管 runner 使用对应平台的 Actions。test/production 部署与 signed/unsigned 模式分别选择。任何 Windows 签名构建或共享 runner 使用前,先阅读 [Windows 签名与 runner 运维](../windows-signing/README.zh.md#trust-and-isolation)。安装与打包分开进行;[发布](../publishing/README.zh.md)还需要单独的显式操作。 -将 `DSH_DESKTOP_PACKAGE_CACHE_DIR` 设为绝对构建缓存目录,即可跨 checkout 保留经过校验的下载文件和 pnpm 内容存储。两个打包工作流使用 `runner.tool_cache/dsh-desktop-package-cache`;本地命令默认使用 `.desktop-build`。runtime 准备仍创建全新安装目录和锁文件、校验缓存包的完整性,并在签名前复制文件。清理只删除临时安装,不删除这些缓存。可写缓存仅供可信构建任务共享;完整性校验不能隔离不可信的写入者。仅在没有任务使用缓存时清理。缓存磁盘用量由操作者管理,不自动淘汰。 +将 `DSH_DESKTOP_PACKAGE_CACHE_DIR` 设为绝对构建缓存目录以跨 checkout 复用;详见[缓存配置与生命周期](#desktop-runtime-preparation)。 @@ -275,7 +275,7 @@ NSIS 在安装阶段清理临时解压目录,完成后才显示完成页或自 Windows 并行准备 Electron 和 primary-runtime;macOS 串行准备二者。只有两份运行时资源均成功,才会准备 CLI 并写入 `versions.json`。任一 Windows 运行时准备失败时,流程会等待另一路结束后再报告失败,且不写入 CLI 资源或版本记录。嵌套准备阶段的计时存在重叠,不能直接相加。 -打包进程环境中的 `DSH_DESKTOP_PACKAGE_CACHE_DIR` 为共享的 `downloads` 和 `pnpm-store` 缓存指定非空绝对目录;省略时使用 `.desktop-build`。临时安装项目、lockfile 和配置仍逐次独立创建。运行时安装校验 store 完整性并复制包文件,防止后续签名修改缓存字节;清理时保留这些缓存。Windows Actions 使用 `runner.tool_cache/dsh-desktop-package-cache`,仓库依赖安装则使用工作区盘根目录下独立的 `.pnpm-store`。两次安装日志均标明各自的 store 并报告包复用情况。每次运行仍通过新的运行时 lockfile 解析依赖。 +打包进程环境中的 `DSH_DESKTOP_PACKAGE_CACHE_DIR` 为共享的 `downloads` 和 `pnpm-store` 缓存指定非空绝对目录;省略时使用 `.desktop-build`。临时安装项目、lockfile 和配置仍逐次独立创建。运行时安装校验 store 完整性并复制包文件,防止后续签名修改缓存字节;清理时保留这些缓存。两个打包工作流使用 `runner.tool_cache/dsh-desktop-package-cache`;Windows 仓库依赖安装使用工作区盘根目录下独立的 `.pnpm-store`。两次安装日志均标明各自的 store 并报告包复用情况。每次运行仍通过新的运行时 lockfile 解析依赖。可写缓存仅供可信构建任务共享;完整性校验不能隔离不可信的写入者。仅在没有任务使用缓存时清理。缓存磁盘用量由操作者管理,不自动淘汰。 Desktop [补丁策略](../scripts/runtime-patch-policy.ts) 将每份工作区补丁明确归为共享、仅工作区或仅运行时。共享项复用根目录补丁字节,并对照根锁文件校验 hash;仅运行时项使用仓库内的独立文件,不改变工作区安装。仅工作区补丁用于构建工具或已嵌入客户端 bundle 的依赖。未分类或过期的条目、文件缺失、hash 改变、解析版本不兼容,以及仅工作区包进入运行时,都会阻止打包。[准备脚本](../scripts/prepare-runtime-patches.ts) 只写入临时项目;pnpm 负责应用选中的补丁,应用失败时会报错。 @@ -299,4 +299,4 @@ Windows 以不带版本后缀的产品名称显示已安装应用,版本保留 ## 开发备注 -TODO:出现磁盘空间不足、缓存持续增长或频繁人工清理时,评估空间监测、维护入口和自动淘汰。这些是延期工作,尚未实现;自动清理需要与所有使用缓存的任务协调。 +XXX:出现磁盘空间不足、缓存持续增长或频繁人工清理时,评估空间监测、维护入口和自动淘汰。任何自动清理方案都需要与所有使用缓存的任务协调。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 8f83b554e1..2d09200c42 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -22,7 +22,7 @@ zh: 29f166be6a5c04d9 /development-guide/contributor-reference/typescript-project-layout: en: 8c004ba883c40653 - zh: f1964048560dc34b + zh: ec5e679339408daa /development-guide/contributor-reference/environment-variables: en: dba2c72ee6ffa930 zh: b65c27fbb9129ec8 diff --git a/docs/development.zh.md b/docs/development.zh.md index 20fcc68413..20edd33aa0 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -78,7 +78,7 @@ Host 与 Client 保持两个 aggregate program,是因为两侧在相同键下 拆分 Host/Client tsconfig 的包有七个:`api/remotes`、`api/gateway`、`api/session-controller`、`api/workspace-controller`、`client/connection`、`session-query/session-log-export` 与 `experimental/liveshare-protocol`。`api/remotes` 的 Host 入口进入 Host Typert 图,而 Client 入口导入生成的 `/remote` 声明;`session-log-export` 则让 Node archive 生产代码不进入浏览器 controller;`liveshare-protocol` 把 Node 线路编解码放在 Host leaf、浏览器查看页载体放在 Client leaf。每个拆分包根 `tsconfig.json` 因此只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf;该门禁按「两个 leaf 配置同时存在」自动发现拆分包,所以新拆分的包会自动纳入管辖。[`api-remotes` README](../packages/api/remotes/README.zh.md) 与 [`session-log-export` README](../packages/session-query/session-log-export/README.zh.md)分别说明其拆分。 -`pnpm run build:artifacts` 产出全部产物,只检查并编译各 compiler face 的包 reference。Client 编译等待 Host 和 Desktop 打包完成:拆分 compiler face 会重写这些打包步骤读取的共享 `lib/types` 文件。Client 打包在 Client 编译后执行。产物步骤顺序为: +`pnpm run build:artifacts` 产出全部产物,只检查并编译各 compiler face 的包 reference。Client 编译等待 Host 和 Desktop 的 bundle 完成:拆分 compiler face 会重写这些 bundle 步骤读取的共享 `lib/types` 文件。Client bundle 在 Client 编译后执行。产物步骤顺序为: ```sh pnpm run build:native-system From dba631dda6708bc73b591c657f1744524641058c Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:38:55 +0800 Subject: [PATCH 45/46] docs(windows): preserve network settings when reinstalling runner --- apps/desktop/windows-signing/README.i18n.yaml | 4 ++-- apps/desktop/windows-signing/README.md | 2 +- apps/desktop/windows-signing/README.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/desktop/windows-signing/README.i18n.yaml b/apps/desktop/windows-signing/README.i18n.yaml index 6ab961c6aa..c1354ca9e8 100644 --- a/apps/desktop/windows-signing/README.i18n.yaml +++ b/apps/desktop/windows-signing/README.i18n.yaml @@ -21,8 +21,8 @@ en: bb889e9514678d1c zh: 48cbec1e907d6a2b /windows-signing-and-runner-operations/machine-setup-and-runner-startup/supervised-startup: - en: 317bd7cbd51a9717 - zh: fd880f204a39eb0f + en: cada81c15a20ecc2 + zh: 80b7a4af7b1e0e5d /windows-signing-and-runner-operations/machine-setup-and-runner-startup/optional-network-dependency: en: 60f34f8637b3c252 zh: 805435964ab20263 diff --git a/apps/desktop/windows-signing/README.md b/apps/desktop/windows-signing/README.md index 10723676dc..f7b48b9b3e 100644 --- a/apps/desktop/windows-signing/README.md +++ b/apps/desktop/windows-signing/README.md @@ -42,7 +42,7 @@ Run `pwsh -NoProfile -File apps/desktop/scripts/windows-runner-task.ps1 -Action The task permits one instance and has no execution time limit. A repeating trigger checks every `-RestartMinutes` minutes (default `1`) and restarts an exited launcher, including an unexpected zero exit. The persisted budget permits the initial launch plus `-RestartCount` restarts (default `10`) between explicit starts. Every desktop login also consumes this budget; `status` shows the remaining launches. After the budget is exhausted, login alone cannot restart the runner. Starting an already running task does not reset the budget. Missing or invalid budget/configuration files disable automatic starts until repaired. GitHub's launcher owns runner updates; task recovery does not rerun a failed workflow or clear signing interlocks. After retries are exhausted, inspect `runner/dsh-task/run-*.log`, the runner's `_diag` logs and `status`, correct the cause, then run `start`. A running task alone does not prove connectivity: verify `Connected to GitHub` and `Listening for Jobs`, then verify assignment in Actions. Both `run-*.log` and `launcher-*.log` are retained for operator-managed cleanup. -Before maintenance, pause new workflow dispatch and wait for the runner to become idle. `-Action stop` refuses an active worker for this runner, disables future starts and stops its task process tree; the local check cannot prevent a concurrent GitHub assignment. `-Action uninstall` requires a stopped, disabled task and retains registration, configuration and logs. Pulling repository changes does not update the installed copy. To upgrade it, record the configuration directory and retry settings, then run `stop`, `uninstall`, `install` with those settings, and `start` from the updated checkout. Confirm listening and job assignment again. When migrating a manually launched runner, stop its existing launcher only after it is idle, then start the task; never run both launchers together. +Before maintenance, pause new workflow dispatch and wait for the runner to become idle. `-Action stop` refuses an active worker for this runner, disables future starts and stops its task process tree; the local check cannot prevent a concurrent GitHub assignment. `-Action uninstall` requires a stopped, disabled task and retains registration, configuration and logs. Pulling repository changes does not update the installed copy. To upgrade it, record the original -ConfigRoot, optional -NetworkRoot and retry settings, then run `stop`, `uninstall`, `install` with those settings, and `start` from the updated checkout. Confirm listening and job assignment again. When migrating a manually launched runner, stop its existing launcher only after it is idle, then start the task; never run both launchers together. Operators can run `pwsh -NoProfile -File apps/desktop/tests/windows-runner-task.ps1` on Windows to exercise installation, console-free initial launch and automatic recovery, duplicate-start prevention, stop, child-process teardown, containment of startup/logging failures and uninstall against an isolated fake runner. This opt-in test creates a temporary task and retains diagnostic files; it does not contact GitHub or access signing hardware. `pwsh -NoProfile -File apps/desktop/tests/windows-runner-launcher.ps1` checks the GUI executable, child console absence, exit status, diagnostic output and bounded draining with an inherited writer without Task Scheduler; Windows CI runs it. Real-machine acceptance additionally checks an idle runner coming online without a visible Terminal window, an Actions job being assigned, and login after an operator-approved reboot. Signed packaging and logout/reboot acceptance require separate operator verification. diff --git a/apps/desktop/windows-signing/README.zh.md b/apps/desktop/windows-signing/README.zh.md index 1583a2f64e..0b7ff50cd2 100644 --- a/apps/desktop/windows-signing/README.zh.md +++ b/apps/desktop/windows-signing/README.zh.md @@ -42,7 +42,7 @@ description: "配置和维护 Windows 打包 runner、签名凭据、EV Token 任务只允许一个实例,且没有执行时限。周期触发器每隔 `-RestartMinutes` 分钟(默认 `1`)检查一次,并恢复已退出的启动器,包括意外的零退出码。持久预算允许首次启动及 `-RestartCount` 次重启(默认 `10`),直到再次显式启动。每次桌面登录也会消耗预算,`status` 显示剩余启动次数;预算耗尽后,仅登录不能重启 runner。对正在运行的任务执行 start 不会重置预算。预算或配置文件缺失、无效时,自动启动会禁用,需修复后恢复。GitHub 启动器负责 runner 更新;任务恢复不会重跑失败的 workflow,也不会清除签名互锁。重试耗尽后,检查 `runner/dsh-task/run-*.log`、runner 的 `_diag` 日志和 `status`,修复原因后运行 `start`。任务运行不等于连接成功:确认出现 `Connected to GitHub` 和 `Listening for Jobs`,再到 Actions 确认任务分配。`run-*.log` 和 `launcher-*.log` 均保留,由操作者管理清理。 -维护前暂停新 workflow 的触发,等待 runner 空闲。`-Action stop` 在该 runner 存在活跃 worker 时拒绝停止,否则禁用后续启动并停止任务进程树;本地检查无法阻止 GitHub 同时分配任务。`-Action uninstall` 要求任务已停止并禁用,保留注册、配置和日志。拉取仓库更新不会更新已安装的副本。升级时先记录配置目录和重试设置,再从更新后的 checkout 依次执行 `stop`、`uninstall`、携带原设置的 `install` 和 `start`,最后重新确认监听与 job 分配。迁移手动启动的 runner 时,只在空闲后停止原启动器,再启动计划任务;不要同时运行两个启动器。 +维护前暂停新 workflow 的触发,等待 runner 空闲。`-Action stop` 在该 runner 存在活跃 worker 时拒绝停止,否则禁用后续启动并停止任务进程树;本地检查无法阻止 GitHub 同时分配任务。`-Action uninstall` 要求任务已停止并禁用,保留注册、配置和日志。拉取仓库更新不会更新已安装的副本。升级时先记录原来的 -ConfigRoot、可选的 -NetworkRoot 和重试设置,再从更新后的 checkout 依次执行 `stop`、`uninstall`、携带原设置的 `install` 和 `start`,最后重新确认监听与 job 分配。迁移手动启动的 runner 时,只在空闲后停止原启动器,再启动计划任务;不要同时运行两个启动器。 操作者可在 Windows 上运行 `pwsh -NoProfile -File apps/desktop/tests/windows-runner-task.ps1`,使用隔离的假 runner 验证安装、无控制台的首次启动与自动恢复、防止重复启动、停止、子进程退出、启动/日志失败时的禁用处理和卸载。此显式运行的测试创建临时任务并保留诊断文件,不连接 GitHub 或访问签名硬件。`pwsh -NoProfile -File apps/desktop/tests/windows-runner-launcher.ps1` 无需计划任务,检查 GUI 可执行文件、子进程无控制台、退出状态、诊断输出以及存在继承管道写端时的有界排空;Windows CI 会执行该测试。真实机器验收还需检查空闲 runner 上线且不出现 Terminal 窗口、Actions job 分配,以及经操作者批准的重启后登录。签名打包和注销/重启验收需要操作者单独验证。 From 048845f84158ca8a30313cc3b1bc9c9c695c811a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:00:47 +0800 Subject: [PATCH 46/46] fix(typert): harden default resource declarations and sync contracts --- ...peer-principal-and-remote-access.i18n.yaml | 16 +++++------ ...-10-12-peer-principal-and-remote-access.md | 10 +++---- ...-12-peer-principal-and-remote-access.zh.md | 10 +++---- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/subsystems/typert.md | 13 +++++---- docs/subsystems/typert.zh.md | 13 +++++---- packages/api/gateway/src/access.ts | 19 +++++++------ .../gateway/tests/gateway-access.host.spec.ts | 28 ++++++++++++++++++- packages/api/session-controller/src/index.ts | 5 ++++ .../tests/controller.host.spec.ts | 1 - .../tests/remote-access.host.spec.ts | 10 +++---- packages/typert/protocol/src/index.ts | 13 +++++---- packages/typert/protocol/src/types.ts | 17 ++++++++--- packages/typert/registry/README.i18n.yaml | 4 +-- packages/typert/registry/README.md | 2 +- packages/typert/registry/README.zh.md | 2 +- packages/typert/registry/src/service.ts | 16 +++++++++-- packages/typert/registry/tests/typert.spec.ts | 20 ++++++++++++- 19 files changed, 141 insertions(+), 62 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml index af7606e73f..a644575d18 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml @@ -27,14 +27,14 @@ en: a01ac57fc71a84f0 zh: 3e488ef3da5e4b8c /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control: - en: a55516339b0d49dd - zh: 3281df9ea7878fc0 + en: 725a2d4145c0a75e + zh: b98a64532cadeaba /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/correspondence-with-rbac: en: a78a2410eb52f777 zh: a868bf1b0e690c9f /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/resources-and-resource-readers-deepseek-ai-dsh-typert-protocol-deepseek-ai-dsh-typert-registry: - en: b79a4e9401b44978 - zh: eaca7cd4d07f485a + en: d50ae22978165c57 + zh: 8c1fcfb7dc2d3367 /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/endpoint-resources: en: 056a141b31270cba zh: 3f6caafb3a13c192 @@ -45,8 +45,8 @@ en: 3c911f57f0f6ca4b zh: 1509781eeda4c922 /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/host-access-check-order: - en: 6c58dc8777e107ef - zh: bc14605c96e4d8f3 + en: 2d97a251cc4c9db0 + zh: df072d5a96a9e82f /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/forwarded-events: en: 5fe34ee5ec31942a zh: ef39b020b0539b8f @@ -75,8 +75,8 @@ en: 305ec45a7cd85ed7 zh: d2cd4209a56b1d0c /agent-note-peer-principals-and-method-level-remote-access-control/alternatives-considered: - en: 130df2981ba550df - zh: e9a0a89af6fb50a3 + en: 60da38bca6e9c3d6 + zh: 61ea578fe8185b61 /agent-note-peer-principals-and-method-level-remote-access-control/consequences: en: 89452c3c3f042e75 zh: 9bcad00a60c65303 diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md index 7e55c0d8ba..6b1cc15750 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md @@ -131,7 +131,7 @@ The single read point is `this.ctx.invocation.peer.principal`; an owner test is ### Method-level access control -Resource types and the `resources` readers of `@Remote` live in `@deepseek-ai/dsh-typert-protocol`; resource kinds live in typert beside lookups and Contexts: `TypertResourceMap` declares the kinds, `typert.resources` holds each kind's default wire field, and lookups and Host Context adapters declare the resource kind their wire identity names. The Gateway resolves one call or forwarded event to the resources it touches. Decisions, list filtering, and the access snapshot live in the new package `@deepseek-ai/dsh-api-remote-access` (`packages/api/remote-access`), which reads no argument field. The Gateway and domain packages depend on remote-access only for types and inject `remoteAccess`; their only value dependency is `dsh-typert-protocol`, so `package-dependency-policy` gains no remote-access entry. Business packages define and register roles, bindings, and policies; liveshare is the first user. Apart from the built-in `global`, no resource-kind name appears in remote-access or the Gateway, which follows the [`packages/api` carries generic orchestration only](../../../../docs/defensive-patterns/remote-and-api.md) review rule. +Resource types and the `resources` readers of `@Remote` live in `@deepseek-ai/dsh-typert-protocol`; resource kinds live in typert beside lookups and Contexts: `TypertResourceMap` declares the kinds, `typert.resources` holds each kind's default wire field or resolver, and lookups and Host Context adapters declare the resource kind their wire identity names. The Gateway resolves one call or forwarded event to the resources it touches. Decisions, list filtering, and the access snapshot live in the new package `@deepseek-ai/dsh-api-remote-access` (`packages/api/remote-access`), which reads no argument field. The Gateway and domain packages depend on remote-access only for types and inject `remoteAccess`; their only value dependency is `dsh-typert-protocol`, so `package-dependency-policy` gains no remote-access entry. Business packages define and register roles, bindings, and policies; liveshare is the first user. Apart from the built-in `global`, no resource-kind name appears in remote-access or the Gateway, which follows the [`packages/api` carries generic orchestration only](../../../../docs/defensive-patterns/remote-and-api.md) review rule. #### Correspondence with RBAC @@ -165,7 +165,7 @@ export interface TypertResourceMap {} // @deepseek-ai/dsh-workspace/types: workspace: TypertResource // Runtime registries in typert -ctx.typert.resources.register(kind, { field }): TypertDisposer +ctx.typert.resources.register(kind, { field } | { resolve }): TypertDisposer ctx.typert.lookups.register(key, { parameter, wire, resource?, … }) // resource: the kind its wire id names ctx.typert.contexts.registerHost(key, { wire, resource?, … }) // a TypertHostContextAdapter @@ -213,7 +213,7 @@ export function RemoteScope | Rule | Content | |---|---| -| Syntax | `@Remote({ name: 'page', resources: { session: (request) => request.address.kind === 'session' ? request.address.sessionId : request.address.childSessionId } })`; a Context method writes `@RemoteScope('agent')` and gets its Session resource from the Agent Context's `resource: 'session'`, and writes `@RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } })` when it needs another resource. Most methods write no `resources` and get their resources from automatic resolution | +| Syntax | `@Remote({ name: 'prompt', resources: { session: (request) => request.sessionId } })` when a method must name its resource by hand; `session/page` and `session/follow` instead rely on the registered `session` default resolver; a Context method writes `@RemoteScope('agent')` and gets its Session resource from the Agent Context's `resource: 'session'`, and writes `@RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } })` when it needs another resource. Most methods write no `resources` and get their resources from automatic resolution | | Resource kinds | The lower-level packages that declare the identity types merge the resource kinds: `session: TypertResource` in `@deepseek-ai/dsh-session/types` and `workspace: TypertResource` in `@deepseek-ai/dsh-workspace/types`. session-controller references the commands package, so a merge in session-controller would make commands reference session-controller and form a project-reference cycle. remote-access builds in `global`, which has no id and stays out of `TypertResourceMap`; typert reserves the name, and `typert.resources.register('global', …)` throws | | Default extraction declarations | The owning domain registers either `{ field }` or `{ resolve }`, never both. SessionController registers `resolveSessionResource`; WorkspaceController registers `workspaceId`. Duplicate kinds are rejected. Lookup and Host Context resource declarations remain independent of these defaults | | Resolution precedence | Per kind: explicit method reader, then lookup and Host Context identities, then the registered default field or resolver. A field reads the named top-level parameter or the first object argument. A resolver receives validated named `args` and ordered parameter descriptions once before lookup resolution. Defaults return one non-empty string or undefined; arrays are invalid | @@ -392,7 +392,7 @@ Every call path (HTTP `/api`, WebSocket streams, in-process `fetch(request, peer | 1 | Admission yields the Peer: network paths from Connection `admit()` (`browserPeer`), in-process paths from the caller. The Gateway checks `peer.signal` before it reads the wire payload | Failed network admission answers HTTP 401 / 403 without entering the Gateway; a disposed Peer: `gateway/peer-disposed` | | 2 | Resolve the endpoint and validate the argument field set (`resolveDescriptor`, `assertExactArguments`), then validate the JSON arguments against their Typert-compiled types, decoding each once | `gateway/invocation-unavailable`, `gateway/ambiguous-endpoint`, `gateway/definition-unavailable`; extra or missing fields: `gateway/arguments-invalid`; a JSON argument failing validation: `gateway/input-invalid` (for owners and non-owners alike, before the access check) | | 3 | Owner pass-through: for `principal.kind === 'owner'` go straight to step 5 | — | -| 4 | Non-owner: the Gateway resolves resources from the arguments validated in step 2 (`resources` readers, then lookup parameters and the `resource` a Context declares, then default fields; lookup and Context identities are wire identities, and neither a lookup nor the Context is resolved), then asks `remoteAccess.decide({ principal, action: endpoint, resources, args })` with the validated arguments as `args`; invalid resources are refused with `resource-invalid` without asking `decide()`; a `deny` decision becomes the thrown error | `remote-access/denied` with details `{ action, reason }` | +| 4 | Non-owner: the Gateway resolves resources from the arguments validated in step 2 (`resources` readers, then lookup parameters and the `resource` a Context declares, then the registered default field or resolver; lookup and Context identities are wire identities, and neither a lookup nor the Context is resolved), then asks `remoteAccess.decide({ principal, action: endpoint, resources, args })` with the validated arguments as `args`; invalid resources are refused with `resource-invalid` without asking `decide()`; a `deny` decision becomes the thrown error | `remote-access/denied` with details `{ action, reason }` | | 5 | Dispatch: `resolveReceiverContext` (which can activate an Agent), lookup resolution (JSON arguments reuse the step 2 values), and the method call | Existing codes unchanged, such as `gateway/lookup-not-found`, `gateway/context-not-found`, `gateway/input-invalid`, `gateway/service-unavailable`, `gateway/method-unavailable`, and business errors | The access check (steps 3–4) precedes lookup and Context resolution, so a refused call activates no Session and causes no other side effect. The only new Gateway error code is `gateway/peer-disposed`; remote-access declares the refusal code `remote-access/denied`. @@ -600,7 +600,7 @@ The review rule's home is the "`@Remote` marks capability methods" section of [R **Refusal reasons from conditions only.** Without a binding there is no condition to give a reason, so a viewer page could not tell "share expired" from "share not writable"; `explain` gives a reason only after the decision is already a refusal and never changes it. -**A global resource-resolver registry (domain packages register recognizers by field name).** Field recognition separates from the method, nothing flags a method signature change, and same-named fields in other packages are misread; resource resolution uses a default convention on the method plus per-method `resources`, and a domain package registers only its own kind's default field. +**A global resource-resolver registry (domain packages register recognizers by field name).** Field recognition separates from the method, nothing flags a method signature change, and same-named fields in other packages are misread; resource resolution uses a default convention on the method plus per-method `resources`, and a domain package registers only its own kind's default field or resolver. **Dot-path resource declarations (`'request.address.sessionId'`).** Types cannot constrain a path; the parameter types of `resources` readers are inferred from the decorated method, so a typo fails compilation. diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md index 47f2ebf445..00e418b8c7 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md @@ -131,7 +131,7 @@ export function principalOf(invocation: RemoteInvocation | undefined): Principal ### 方法级访问控制 -资源类型与 `@Remote` 的 `resources` 读取函数在 `@deepseek-ai/dsh-typert-protocol`;资源种类在 typert 中与 lookups、Contexts 并列:`TypertResourceMap` 声明种类,`typert.resources` 保存每个种类的默认 wire 字段,lookup 与 Host Context adapter 声明其 wire 身份所属的资源种类。Gateway 把一次调用或一个转发事件解析为它访问的资源。判定、列表过滤与访问快照在新包 `@deepseek-ai/dsh-api-remote-access`(`packages/api/remote-access`),该包不读取任何参数字段。Gateway 与领域包对 remote-access 只做 type-only 依赖并 inject `remoteAccess`,值依赖只有 `dsh-typert-protocol`,不为 remote-access 新增 `package-dependency-policy` 条目。role、binding 与 policy 由业务包定义和注册,liveshare 为首个使用方。除内置的 `global` 外,remote-access 与 Gateway 中不出现任何资源种类名,符合 [`packages/api` 只承载通用编排](../../../../docs/defensive-patterns/remote-and-api.zh.md) 的评审规则。 +资源类型与 `@Remote` 的 `resources` 读取函数在 `@deepseek-ai/dsh-typert-protocol`;资源种类在 typert 中与 lookups、Contexts 并列:`TypertResourceMap` 声明种类,`typert.resources` 保存每个种类的默认 wire 字段或 resolver,lookup 与 Host Context adapter 声明其 wire 身份所属的资源种类。Gateway 把一次调用或一个转发事件解析为它访问的资源。判定、列表过滤与访问快照在新包 `@deepseek-ai/dsh-api-remote-access`(`packages/api/remote-access`),该包不读取任何参数字段。Gateway 与领域包对 remote-access 只做 type-only 依赖并 inject `remoteAccess`,值依赖只有 `dsh-typert-protocol`,不为 remote-access 新增 `package-dependency-policy` 条目。role、binding 与 policy 由业务包定义和注册,liveshare 为首个使用方。除内置的 `global` 外,remote-access 与 Gateway 中不出现任何资源种类名,符合 [`packages/api` 只承载通用编排](../../../../docs/defensive-patterns/remote-and-api.zh.md) 的评审规则。 #### 与 RBAC 的对应 @@ -165,7 +165,7 @@ export interface TypertResourceMap {} // @deepseek-ai/dsh-workspace/types: workspace: TypertResource // Runtime registries in typert -ctx.typert.resources.register(kind, { field }): TypertDisposer +ctx.typert.resources.register(kind, { field } | { resolve }): TypertDisposer ctx.typert.lookups.register(key, { parameter, wire, resource?, … }) // resource: the kind its wire id names ctx.typert.contexts.registerHost(key, { wire, resource?, … }) // a TypertHostContextAdapter @@ -213,7 +213,7 @@ export function RemoteScope | 规则 | 内容 | |---|---| -| 写法 | `@Remote({ name: 'page', resources: { session: (request) => request.address.kind === 'session' ? request.address.sessionId : request.address.childSessionId } })`;Context 方法写 `@RemoteScope('agent')`,由 Agent Context 的 `resource: 'session'` 得到会话资源,需要其他资源时写 `@RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } })`。多数方法不写 `resources`,由自动解析得到资源 | +| 写法 | 方法必须手工点名资源时写 `@Remote({ name: 'prompt', resources: { session: (request) => request.sessionId } })`;`session/page` 与 `session/follow` 则依赖已注册的 `session` 默认 resolver;Context 方法写 `@RemoteScope('agent')`,由 Agent Context 的 `resource: 'session'` 得到会话资源,需要其他资源时写 `@RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } })`。多数方法不写 `resources`,由自动解析得到资源 | | 资源种类 | 资源种类由声明 id 类型的底层包合并:`session: TypertResource` 在 `@deepseek-ai/dsh-session/types`,`workspace: TypertResource` 在 `@deepseek-ai/dsh-workspace/types`。session-controller 引用 commands 包,若在 session-controller 合并,commands 要引用 session-controller,形成 project reference 环。`global` 由 remote-access 内置,没有 id,不进 `TypertResourceMap`;typert 保留该名,`typert.resources.register('global', …)` 抛错 | | 默认提取声明 | 资源所属领域登记 `{ field }` 或 `{ resolve }`,两者互斥。SessionController 登记 `resolveSessionResource`;WorkspaceController 登记 `workspaceId`。重复种类被拒绝;lookup 和 Host Context 的资源声明独立于默认规则 | | 解析优先级 | 每个种类依次采用方法显式 reader、lookup 与 Host Context 身份、注册的默认字段或 resolver。字段读取同名顶层参数或首个对象参数。resolver 在 lookup 解析前调用一次,接收已校验的命名 `args` 及有序参数描述。默认规则返回一个非空字符串或 undefined,数组非法 | @@ -392,7 +392,7 @@ const childPrompt = child => ({ action, args }) => | 1 | 准入得到 Peer:网络路径由 Connection `admit()` 给出(`browserPeer`);进程内路径由调用方传入。Gateway 在读取 wire payload 之前检查 `peer.signal` | 网络准入失败返回 HTTP 401 / 403(不进入 Gateway);Peer 已 dispose:`gateway/peer-disposed` | | 2 | 解析端点并做参数字段集校验(`resolveDescriptor`、`assertExactArguments`),再按 Typert 编译类型校验 JSON 参数,每个参数解码一次 | `gateway/invocation-unavailable`、`gateway/ambiguous-endpoint`、`gateway/definition-unavailable`;字段多余或缺失:`gateway/arguments-invalid`;JSON 参数校验失败:`gateway/input-invalid`(owner 与非 owner 相同,先于访问检查) | | 3 | owner 直通:`principal.kind === 'owner'` 时直接进入第 5 步 | — | -| 4 | 非 owner:Gateway 从第 2 步校验后的参数解析资源(`resources` 读取函数、其次 lookup 参数与 Context 声明的 `resource`、再次默认字段;lookup 与 Context 身份取 wire 身份,不解析 lookup 与 Context),再以校验后的参数为 `args` 询问 `remoteAccess.decide({ principal, action: endpoint, resources, args })`;资源非法时不询问 `decide()`,直接以 `resource-invalid` 拒绝;`deny` 结论变为抛出的错误 | `remote-access/denied`,details `{ action, reason }` | +| 4 | 非 owner:Gateway 从第 2 步校验后的参数解析资源(`resources` 读取函数、其次 lookup 参数与 Context 声明的 `resource`、再次注册的默认字段或 resolver;lookup 与 Context 身份取 wire 身份,不解析 lookup 与 Context),再以校验后的参数为 `args` 询问 `remoteAccess.decide({ principal, action: endpoint, resources, args })`;资源非法时不询问 `decide()`,直接以 `resource-invalid` 拒绝;`deny` 结论变为抛出的错误 | `remote-access/denied`,details `{ action, reason }` | | 5 | 分派:`resolveReceiverContext`(可能激活 Agent)、lookup 解析(JSON 参数复用第 2 步的值)、调用方法 | 既有错误码不变,如 `gateway/lookup-not-found`、`gateway/context-not-found`、`gateway/input-invalid`、`gateway/service-unavailable`、`gateway/method-unavailable` 与业务错误 | 鉴权(第 3–4 步)位于 lookup 与 Context 解析之前,未授权调用不会激活会话或产生其他副作用。Gateway 新增的错误码只有 `gateway/peer-disposed`;拒绝码 `remote-access/denied` 由 remote-access 声明。 @@ -600,7 +600,7 @@ GlobalStandardProps.useWorkspaceAccess: (workspaceId: WorkspaceId, action: Remot **拒绝原因只由 condition 给出。** 没有 binding 时没有 condition 可以给出原因,读者页面无法区分「分享已过期」「分享不允许写入」等情况;`explain` 只在结论已为拒绝后给出原因,不改变结论。 -**全局资源 resolver 注册表(领域包按字段名注册识别函数)。** 字段识别与方法分离,方法改签名时无人提醒,跨包同名字段也会被误认;资源解析采用方法上的默认约定加 per-method `resources`,领域包只登记自己种类的默认字段。 +**全局资源 resolver 注册表(领域包按字段名注册识别函数)。** 字段识别与方法分离,方法改签名时无人提醒,跨包同名字段也会被误认;资源解析采用方法上的默认约定加 per-method `resources`,领域包只登记自己种类的默认字段或 resolver。 **按点路径声明资源(`'request.address.sessionId'`)。** 路径无法由类型推导约束;`resources` 读取函数的参数类型从被装饰方法推断,拼错即编译失败。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4f09e1c051..054da8989d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -243,7 +243,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-session-controller` - `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` -- `source`: [`packages/api/session-controller/src/index.ts:104`](../packages/api/session-controller/src/index.ts) +- `source`: [`packages/api/session-controller/src/index.ts:109`](../packages/api/session-controller/src/index.ts) ```ts config-catalog /** Session Controller deployment policy. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 003c86b399..5981a73eb9 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -245,7 +245,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-session-controller` - `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` -- `source`: [`packages/api/session-controller/src/index.ts:104`](../packages/api/session-controller/src/index.ts) +- `source`: [`packages/api/session-controller/src/index.ts:109`](../packages/api/session-controller/src/index.ts) ```ts config-catalog /** Session Controller deployment policy. */ diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index 9c0a98dff3..9838c8c82d 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -334,11 +334,14 @@ interface RemoteScopeOptions { * throw, an empty list, or an identity other than a non-empty string * refuses the call. For a kind without a reader, the Gateway reads the * identity of a scoped Context whose Host adapter declares that `resource` - * and of lookup parameters declaring it, else the field registered in - * `typert.resources`: a top-level argument or, failing that, the first - * object argument. A call that carries no identity touches `global`. The - * readers and these defaults together must name every resource the method - * accesses: access control never checks a resource they omit. + * and of lookup parameters declaring it, else the registered default of + * `typert.resources`: a `resolve` function receiving the validated named + * wire values and their ordered parameters, or a `field` naming a top-level + * argument or, failing that, a field of the first object argument. A + * throwing resolver, an array result, or a field holding an array refuses + * the call. A call that carries no identity touches `global`. The readers + * and these defaults together must name every resource the method accesses: + * access control never checks a resource they omit. */ readonly resources?: TypertResourceReaders } diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index 8c19d9fb8b..2cbdaf4a2c 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -334,11 +334,14 @@ interface RemoteScopeOptions { * throw, an empty list, or an identity other than a non-empty string * refuses the call. For a kind without a reader, the Gateway reads the * identity of a scoped Context whose Host adapter declares that `resource` - * and of lookup parameters declaring it, else the field registered in - * `typert.resources`: a top-level argument or, failing that, the first - * object argument. A call that carries no identity touches `global`. The - * readers and these defaults together must name every resource the method - * accesses: access control never checks a resource they omit. + * and of lookup parameters declaring it, else the registered default of + * `typert.resources`: a `resolve` function receiving the validated named + * wire values and their ordered parameters, or a `field` naming a top-level + * argument or, failing that, a field of the first object argument. A + * throwing resolver, an array result, or a field holding an array refuses + * the call. A call that carries no identity touches `global`. The readers + * and these defaults together must name every resource the method accesses: + * access control never checks a resource they omit. */ readonly resources?: TypertResourceReaders } diff --git a/packages/api/gateway/src/access.ts b/packages/api/gateway/src/access.ts index c1a727c0ab..f68079c47e 100644 --- a/packages/api/gateway/src/access.ts +++ b/packages/api/gateway/src/access.ts @@ -25,7 +25,7 @@ type ResourceIds = readonly unknown[] | 'invalid' * the kind; otherwise the kind's registered default resolver receives named * wire values and ordered parameter descriptions. A default field instead * reads a top-level argument or, failing that, the first object argument. - * @param typert - registry holding default fields, lookup providers, and Host Context adapters. + * @param typert - registry holding default fields or resolvers, lookup providers, and Host Context adapters. * @param readers - the method's `resources` readers, or `undefined` when it declares none. * @param descriptor - receiver, parameter order, lookups, and cancellation slot of the method. * @param args - named arguments matched to the descriptor's field set: JSON @@ -33,9 +33,9 @@ type ResourceIds = readonly unknown[] | 'invalid' * identities as wire values. * @param signal - value placed in the cancellation slot of the positional arguments. * @returns the touched resources, the global resource alone when none; - * `undefined` when the resources are invalid: a reader throws or returns - * an empty list, a default field holds an array, or a resource id is not a - * non-empty string. + * `undefined` when the resources are invalid: a reader or resolver throws, + * a reader returns an empty list, a default field or resolver holds an + * array, or a resource id is not a non-empty string. */ export function callResources( typert: Pick, @@ -52,17 +52,18 @@ export function callResources( ] for (const declaration of typert.resources.entries()) { sources.set(declaration.kind, () => { + let id: unknown if (declaration.resolve !== undefined) { - let id: unknown + const resolve = declaration.resolve try { - id = declaration.resolve({ args, parameters: descriptor.parameters }) - } catch (error) { + id = resolve({ args, parameters: descriptor.parameters }) + } catch { // A resolver failure cannot identify an authorized resource. return 'invalid' } - return Array.isArray(id) ? 'invalid' : id === undefined ? [] : [id] + } else { + id = defaultFieldId(descriptor, args, declaration.field) } - const id = defaultFieldId(descriptor, args, declaration.field) return Array.isArray(id) ? 'invalid' : id === undefined ? [] : [id] }) } diff --git a/packages/api/gateway/tests/gateway-access.host.spec.ts b/packages/api/gateway/tests/gateway-access.host.spec.ts index 15e500c1d3..53727804ee 100644 --- a/packages/api/gateway/tests/gateway-access.host.spec.ts +++ b/packages/api/gateway/tests/gateway-access.host.spec.ts @@ -49,6 +49,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' { /** Test-only resource kinds; the Gateway itself names no kind. */ 'test-shelf': TypertResource 'test-desk': TypertResource + 'test-frame': TypertResource } } @@ -59,11 +60,12 @@ const GUEST: Guest = { kind: 'test-guest', guest: 'gateway' } const GLOBAL = { kind: 'global' } as const const shelf = (id: string) => ({ kind: 'test-shelf', id }) as const const desk = (id: string) => ({ kind: 'test-desk', id }) as const +const frame = (id: string) => ({ kind: 'test-frame', id }) as const const FIXTURE_ACTIONS: readonly RemoteAccessAction[] = [ 'accessFixture/open', 'accessFixture/read', 'accessFixture/byAgent', 'accessFixture/byLookup', 'accessFixture/byLookupAndOption', 'accessFixture/byList', 'accessFixture/scoped', 'accessFixture/scopedOnDesk', - 'accessFixture/scopedByReader', 'accessFixture/annotate', 'absentAccess/read', 'event:fixture/notice', 'event:fixture/shelf', + 'accessFixture/scopedByReader', 'accessFixture/annotate', 'accessFixture/bySession', 'absentAccess/read', 'event:fixture/notice', 'event:fixture/shelf', ] function role(resource: K): RemoteAccessRole { @@ -79,6 +81,8 @@ function on(resource: RemoteAccessResource): RemoteAccessBinding { return { role: role('test-shelf'), resource } case 'test-desk': return { role: role('test-desk'), resource } + case 'test-frame': + return { role: role('test-frame'), resource } default: throw new Error('no fixture role for this resource kind') } @@ -127,6 +131,12 @@ class AccessFixture extends TypertRemoteService { return this.seen() } + @Remote + bySession(sessionId: string): string { + void sessionId + return this.seen() + } + @Remote annotate(request: { readonly title: string }): string { this.requests.push(request) @@ -366,6 +376,22 @@ describe('Gateway access checks', () => { expect(decisions()).toEqual([{ action: 'absentAccess/read', resources: [GLOBAL] }]) }) + it('passes declared parameter names to a registered resolver through dispatch', async () => { + const { ctx, hold } = await setup() + const resolve = vi.fn(({ args, parameters }: TypertResourceResolveInput) => { + const named = parameters.find(parameter => parameter.name === 'sessionId') + return named === undefined ? undefined : args[named.wire] as string + }) + const dispose = ctx.typert.resources.register('test-frame', { resolve }) + const guest = peerFor(ctx, GUEST) + hold(frame('f1')) + await expect(call(ctx, guest, 'bySession', { sessionId: 'f1' })).resolves.toBe('test-guest') + await expect(call(ctx, guest, 'bySession', { sessionId: 'f2' })) + .rejects.toMatchObject({ code: 'remote-access/denied' }) + expect(resolve).toHaveBeenCalledTimes(2) + await dispose() + }) + it('checks access before resolving lookups, so a refused call activates nothing', async () => { const { ctx, hold } = await setup() const resolve = vi.fn((id: string) => ({ id })) diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index c234725c25..125bd1f3e3 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -79,6 +79,11 @@ declare module '@deepseek-ai/cordis' { /** * Extract the Session resource from an ID or a history address. + * + * Registers as the `session` kind default, so it applies to every Remote method of + * every namespace: a top-level `sessionId` parameter, otherwise the first object + * parameter carrying `sessionId` or an `address`. A new request type whose first + * object parameter has an unrelated `address` field would be read as an address. * @param input - validated wire arguments and their ordered parameter descriptions. * @returns the named Session identity, otherwise the first object's Session identity or history address. * @throws Error when the request object supplies both sessionId and address. diff --git a/packages/api/session-controller/tests/controller.host.spec.ts b/packages/api/session-controller/tests/controller.host.spec.ts index 15c438764a..a91b9a15c0 100644 --- a/packages/api/session-controller/tests/controller.host.spec.ts +++ b/packages/api/session-controller/tests/controller.host.spec.ts @@ -63,7 +63,6 @@ describe('SessionController facade', () => { ctx.on('api-session/error', failure) ctx.on('api-session/activity', activity) - const session = ctx.sessions.create(sessionId, { meta: header }) const agent = { id: sessionId, diff --git a/packages/api/session-controller/tests/remote-access.host.spec.ts b/packages/api/session-controller/tests/remote-access.host.spec.ts index 5d17849476..e15b7b3fb1 100644 --- a/packages/api/session-controller/tests/remote-access.host.spec.ts +++ b/packages/api/session-controller/tests/remote-access.host.spec.ts @@ -1,10 +1,10 @@ /** * Session Controller under non-owner callers: the Session resource kind it - * registers, the Session readers of `session/page` and `session/follow`, - * `session/list`, `session/search`, and `session/control` called through a - * `global` role and filtered per Session through `remoteAccess.allows()`, the caller recorded - * on prompt sources, and `session/projections` admitted per Session through - * the Gateway. + * registers and its default resolver, which supplies the identities of + * `session/page` and `session/follow`, `session/list`, `session/search`, and + * `session/control` called through a `global` role and filtered per Session + * through `remoteAccess.allows()`, the caller recorded on prompt sources, and + * `session/projections` admitted per Session through the Gateway. */ import { Context } from '@deepseek-ai/cordis' diff --git a/packages/typert/protocol/src/index.ts b/packages/typert/protocol/src/index.ts index ac8e23ecff..7972cd5ef6 100644 --- a/packages/typert/protocol/src/index.ts +++ b/packages/typert/protocol/src/index.ts @@ -158,11 +158,14 @@ export interface RemoteScopeOptions * throw, an empty list, or an identity other than a non-empty string * refuses the call. For a kind without a reader, the Gateway reads the * identity of a scoped Context whose Host adapter declares that `resource` - * and of lookup parameters declaring it, else the field registered in - * `typert.resources`: a top-level argument or, failing that, the first - * object argument. A call that carries no identity touches `global`. The - * readers and these defaults together must name every resource the method - * accesses: access control never checks a resource they omit. + * and of lookup parameters declaring it, else the registered default of + * `typert.resources`: a `resolve` function receiving the validated named + * wire values and their ordered parameters, or a `field` naming a top-level + * argument or, failing that, a field of the first object argument. A + * throwing resolver, an array result, or a field holding an array refuses + * the call. A call that carries no identity touches `global`. The readers + * and these defaults together must name every resource the method accesses: + * access control never checks a resource they omit. */ readonly resources?: TypertResourceReaders } diff --git a/packages/typert/protocol/src/types.ts b/packages/typert/protocol/src/types.ts index 2bf63755f7..e7ba539879 100644 --- a/packages/typert/protocol/src/types.ts +++ b/packages/typert/protocol/src/types.ts @@ -880,17 +880,26 @@ export interface TypertContextRegistry { /** Validated wire values and their ordered business parameters for resource extraction. */ export interface TypertResourceResolveInput { + /** Wire field to its validated value, including lookup and Context identities as wire values. */ readonly args: Readonly> + /** Declared parameters in call order, so a resolver can match a name or an aliased wire field. */ readonly parameters: readonly Pick[] } -/** Default identity extraction for one resource kind. */ +/** Default identity extraction for one resource kind; exactly one of `field` and `resolve` is declared. */ export type TypertResourceDeclaration = - | { readonly field: string; readonly resolve?: never } + | { + readonly field: string + readonly resolve?: never + } | { readonly field?: never - /** Called once before lookups resolve. Returns one identity, or undefined when absent; throws to reject the resource. */ - resolve(input: TypertResourceResolveInput): string | undefined + /** + * Called once before lookups resolve. It takes no receiver, so it reads only its + * input. Returns one identity, or undefined when absent; throws or returns an + * array to reject the call. + */ + resolve(this: void, input: TypertResourceResolveInput): string | undefined } /** One registered resource kind with its default identity extraction. */ diff --git a/packages/typert/registry/README.i18n.yaml b/packages/typert/registry/README.i18n.yaml index ee88fadfc3..39d17184d8 100644 --- a/packages/typert/registry/README.i18n.yaml +++ b/packages/typert/registry/README.i18n.yaml @@ -27,8 +27,8 @@ en: 9ca425515904d5c9 zh: b251226ecfa32002 /deepseek-ai-dsh-typert-registry/use-this-package/lookup-and-context-providers: - en: a1ba884253d02d96 - zh: 90165b44da9473f0 + en: a5392d1b04cde813 + zh: 20e09e9a70701821 /deepseek-ai-dsh-typert-registry/understand-the-implementation: en: 1a78dfbae50a8b0d zh: dc10b4b3f3d832ed diff --git a/packages/typert/registry/README.md b/packages/typert/registry/README.md index 0e2822a1c9..ed890801ed 100644 --- a/packages/typert/registry/README.md +++ b/packages/typert/registry/README.md @@ -51,7 +51,7 @@ Harness offline integrations register an ordinary `TYPERT_REMOTE` contribution w Remote calls resolve Host objects and scoped Contexts through `ctx.typert.lookups` and `ctx.typert.contexts`. `registerHost()` installs the Host wire declaration and its wire-to-Context resolver, while `configureHost()` replaces only that resolver. `registerClient()` installs the bidirectional Client adapter for the same merge-declared kind. Host-to-Client event sources carry their domain identity explicitly instead of projecting it back from a Context. -`ctx.typert.resources` holds the default identity extraction of each resource kind declared in `TypertResourceMap`. The domain that owns a kind registers either `{ field }` or `{ resolve }`, never both, as a Cordis effect of the calling fiber; a second registration of one kind throws, and so does `global`, the built-in identity-less resource kind. `get(kind)` reads one declaration, and `entries()` lists each `kind` with its field or resolver declaration in registration order. A lookup provider and a Host Context adapter may name the resource kind of their wire identity through `resource`, and a configured resolver keeps it. The Gateway reads all three when it resolves the resources of a non-owner call. +`ctx.typert.resources` holds the default identity extraction of each resource kind declared in `TypertResourceMap`. The domain that owns a kind registers either `{ field }` or `{ resolve }`, never both, as a Cordis effect of the calling fiber; a second registration of one kind throws, and so does `global`, the built-in identity-less resource kind. A resolver is called once, as a plain function without a receiver, so it reads only its `{ args, parameters }` input; a declaration with neither member throws at registration. `get(kind)` reads one declaration, and `entries()` lists each `kind` with its field or resolver declaration in registration order. A lookup provider and a Host Context adapter may name the resource kind of their wire identity through `resource`, and a configured resolver keeps it. The Gateway reads all three when it resolves the resources of a non-owner call. ----- diff --git a/packages/typert/registry/README.zh.md b/packages/typert/registry/README.zh.md index c53ca04273..ded364311c 100644 --- a/packages/typert/registry/README.zh.md +++ b/packages/typert/registry/README.zh.md @@ -51,7 +51,7 @@ Harness 离线集成在各自的 profile Context 中通过 `ctx.typert.remotes.r Remote 调用通过 `ctx.typert.lookups` 与 `ctx.typert.contexts` 解析 Host 对象与作用域 Context。`registerHost()` 安装 Host wire 声明及其 wire 到 Context 的 resolver,`configureHost()` 只替换该 resolver。`registerClient()` 为同一个 merge-declared kind 安装双向 Client adapter。Host 到 Client 的事件源显式携带领域 identity,而不从 Context 反向投影。 -`ctx.typert.resources` 保存 `TypertResourceMap` 中每个资源种类的默认身份提取规则。拥有该种类的领域登记 `{ field }` 或 `{ resolve }`,不能同时提供两者,登记是调用方 fiber 的 Cordis effect;同一种类重复登记即抛错,登记内置的无身份资源种类 `global` 同样抛错。`get(kind)` 读取一个种类的声明,`entries()` 按登记顺序列出每个 `kind` 及其字段或 resolver 声明。lookup provider 与 Host Context adapter 可经 `resource` 声明其 wire 身份所属的资源种类,配置的 resolver 保留该声明。Gateway 解析非 owner 调用的资源时读取这三者。 +`ctx.typert.resources` 保存 `TypertResourceMap` 中每个资源种类的默认身份提取规则。拥有该种类的领域登记 `{ field }` 或 `{ resolve }`,不能同时提供两者,登记是调用方 fiber 的 Cordis effect;同一种类重复登记即抛错,登记内置的无身份资源种类 `global` 同样抛错。resolver 只以纯函数形式调用一次、不接收 receiver,因此只读取其 `{ args, parameters }` 输入;两个成员都不提供的声明在登记时抛错。`get(kind)` 读取一个种类的声明,`entries()` 按登记顺序列出每个 `kind` 及其字段或 resolver 声明。lookup provider 与 Host Context adapter 可经 `resource` 声明其 wire 身份所属的资源种类,配置的 resolver 保留该声明。Gateway 解析非 owner 调用的资源时读取这三者。 ----- diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index 9007b9eee6..b233845638 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -471,9 +471,21 @@ class ResourceStore { if (kind === GLOBAL_RESOURCE_KIND) { throw new Error(`typert: resource kind "${GLOBAL_RESOURCE_KIND}" is reserved for the built-in global resource`) } - if (declaration.field !== undefined) validateWireName('resource field', declaration.field) if (this.declarations.has(kind)) throw new Error(`typert: resource kind "${kind}" is already registered`) - const entry: ProviderEntry = { provider: { ...declaration }, owner: {} } + // Copy by branch so a prototype method cannot vanish in a spread; an opaque declaration fails here + // rather than resolving every call of this kind to the global resource. + const resolve = declaration.resolve + const field = declaration.field + let provider: TypertResourceDeclaration + if (resolve !== undefined) { + provider = { resolve } + } else if (field !== undefined) { + validateWireName('resource field', field) + provider = { field } + } else { + throw new Error(`typert: resource kind "${kind}" declares neither a field nor a resolve function`) + } + const entry: ProviderEntry = { provider, owner: {} } const { declarations, changes } = this return ctx.effect(function* () { declarations.set(kind, entry) diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 966c730767..12907ec5f5 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -11,6 +11,7 @@ import type { InvocationDescriptor, TypertContext, TypertResource, + TypertResourceDeclaration, TypertResourceKind, TypertLookup, TypertRegistryChange, @@ -453,7 +454,7 @@ describe('TypertRegistry', () => { const ctx = await makeCtx() const resolve = () => 'fixture-id' const changes: TypertRegistryChange[] = [] - ctx.typert.resources.subscribe(change => { changes.push(change) }) + ctx.typert.resources.subscribe((change) => { changes.push(change) }) const fiber = ctx.plugin({ inject: ['typert'], apply: (pluginCtx: Context) => { pluginCtx.typert.resources.register('registryFixture', { resolve }) }, @@ -471,6 +472,23 @@ describe('TypertRegistry', () => { ]) }) + it('keeps a prototype resolver callable and rejects a declaration that names no default', async () => { + const ctx = await makeCtx() + class PrototypeResolver { + resolve(): string { return 'prototype-id' } + } + const declaration: TypertResourceDeclaration = new PrototypeResolver() + ctx.typert.resources.register('registryFixture', declaration) + const resolve = ctx.typert.resources.get('registryFixture')?.resolve + expect(resolve?.({ args: {}, parameters: [] })).toBe('prototype-id') + const opaque = {} as TypertResourceDeclaration + const other = new Context() + await other.plugin(TypertRegistry) + expect(() => other.typert.resources.register('registryFixture', opaque)) + .toThrow('declares neither a field nor a resolve function') + await other.fiber.dispose() + }) + it('reserves the built-in global resource kind', async () => { const ctx = await makeCtx() const changes: TypertRegistryChange[] = []