From 099db911695ebd5d3cd78530aa4df2f6b705892d Mon Sep 17 00:00:00 2001 From: kingwl Date: Wed, 30 Sep 2026 14:27:13 +0800 Subject: [PATCH 1/2] fix: preserve bundled Python interpreter aliases --- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/scripts/macos-runtime.ts | 24 +++++++-- apps/desktop/scripts/prepare-dsh.ts | 2 +- apps/desktop/tests/macos-runtime.spec.ts | 33 +++++++++++- .../README.i18n.yaml | 4 +- .../tool-workspace-dependencies/README.md | 2 + .../tool-workspace-dependencies/README.zh.md | 2 + .../tool-workspace-dependencies/src/index.ts | 51 ++++++++++++++++-- .../tests/tool-workspace-dependencies.spec.ts | 54 ++++++++++++++++++- scripts/primary-runtime/prepare.spec.ts | 11 ++++ scripts/primary-runtime/prepare.ts | 18 +++++-- scripts/primary-runtime/smoke.py | 22 ++++++++ 14 files changed, 210 insertions(+), 21 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index f412464ae6..896c1f1b1d 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -15,8 +15,8 @@ en: 069fdfd72d2ea26a zh: 76ae549d43c99bb4 /deepseek-harness-desktop/key-technical-decisions/bundled-workspace-dependencies: - en: 320733f93ede61a4 - zh: 33206c3c4753c802 + en: 22bff5d75f65eab7 + zh: 7a579d9b300d2765 /deepseek-harness-desktop/bundled-command-runtime: en: beecf076aa2f0796 zh: c9001ce0f22d2379 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index fa00c27e08..15a4316e29 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -68,7 +68,7 @@ The payload follows the Desktop release. `runtime.json` records the Desktop vers The private Desktop `runtime/bin` directory is added only to package-installation processes, not the Host PATH inherited by PTC and agent shells. This tool does not change PATH, environment variables or user package-manager configuration. pnpm retains its own defaults and user settings for global packages, executable entries and its store, including native errors when the environment does not support global installation. There is no separate dependency updater. [The primary-runtime decision](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md) records these choices. -Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](../../scripts/primary-runtime/lock.json) pins interpreter archives, Python distribution versions and target-specific wheel URLs and hashes; the shared builder resolves pnpm from the root development dependency pin. The root package-manager version and the Desktop pin are checked for agreement. Wheel filenames and distribution versions must agree for every target. Preserve key order within the selected target, wheel records and distribution map, plus wheel-entry order; these affect payload identity, while top-level lock key order does not. Library wheels unpack into site-packages, retaining auxiliary files under each wheel's `.data/scripts` directory without generating command wrappers. Other installation schemes are rejected. Native-target checks verify the locked wheel set and versions, permit the interpreter's bundled pip, and check the Python version, Office document read/write operations and dependency completeness without writing bytecode after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8; the x64 target also receives `com.apple.security.cs.allow-unsigned-executable-memory` for executable code pages, including under Rosetta. ARM64 Node and Office helpers retain JIT-only permissions. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets//runtime/primary-runtime` before launching Electron; first use may download locked dependencies. An unfinished preparation cannot report a successful launcher exit. +Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](../../scripts/primary-runtime/lock.json) pins interpreter archives, Python distribution versions and target-specific wheel URLs and hashes; the shared builder resolves pnpm from the root development dependency pin. The root package-manager version and the Desktop pin are checked for agreement. Wheel filenames and distribution versions must agree for every target. Preserve key order within the selected target, wheel records and distribution map, plus wheel-entry order; these affect payload identity, while top-level lock key order does not. Library wheels unpack into site-packages, retaining auxiliary files under each wheel's `.data/scripts` directory without generating command wrappers. Other installation schemes are rejected. macOS signs the versioned Python executable once, retaining the [two interpreter aliases](../../packages/skill/tool-workspace-dependencies/README.md) in external resources and installed copies. POSIX native-target checks require relative interpreter aliases. All native-target checks exercise command names, child execution and a pip-enabled venv, verify the locked wheel set and versions, permit the interpreter's bundled pip, and check the Python version, Office document read/write operations and dependency completeness without writing bytecode after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8; the x64 target also receives `com.apple.security.cs.allow-unsigned-executable-memory` for executable code pages, including under Rosetta. ARM64 Node and Office helpers retain JIT-only permissions. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets//runtime/primary-runtime` before launching Electron; first use may download locked dependencies. An unfinished preparation cannot report a successful launcher exit. | Decision | Why | Direct consequence | |---|---|---| diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 61caaf038d..161be5bf80 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -70,7 +70,7 @@ Desktop 默认注册 `office-docx`、`office-pptx` 和 `office-xlsx`。这些技 Desktop 私有的 `runtime/bin` 目录仅添加到包安装进程,不进入 PTC 和 agent shell 从 Host 继承的 PATH。该工具不修改 PATH、环境变量或用户包管理器配置。pnpm 的全局包、命令入口和 store 保留自身默认值及用户设置,包括环境不支持全局安装时的原生错误。不提供独立依赖更新器。[第一方 Runtime 决策](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md)记录这些选择。 -Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](../../scripts/primary-runtime/lock.json)固定解释器压缩包、Python 分发包版本及目标平台 wheel 的 URL 和哈希;共享构建器从根开发依赖中解析 pnpm 固定版本。测试检查根 package-manager 版本和 Desktop 固定版本保持一致。每个目标的 wheel 文件名必须与分发包版本一致。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序都会影响产物身份,编辑时须保留;锁文件顶层键的顺序不影响该身份。库 wheel 解压到 site-packages,各 wheel 的 `.data/scripts` 目录保留辅助文件,不生成命令行包装器。其他安装方案会被拒绝。本机目标检查在清理暂存目录后以及 macOS 签名后验证锁定 wheel 的集合与版本,允许解释器自带的 pip,并检查 Python 版本、Office 文档读写和依赖完整性,不写入字节码。独立 Node 可执行文件获得 V8 所需的 JIT 权限;x64 目标还获得 `com.apple.security.cs.allow-unsigned-executable-memory`,允许可执行代码页,包括在 Rosetta 下运行时。ARM64 Node 和 Office 辅助程序保留仅 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets//runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。 +Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](../../scripts/primary-runtime/lock.json)固定解释器压缩包、Python 分发包版本及目标平台 wheel 的 URL 和哈希;共享构建器从根开发依赖中解析 pnpm 固定版本。测试检查根 package-manager 版本和 Desktop 固定版本保持一致。每个目标的 wheel 文件名必须与分发包版本一致。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序都会影响产物身份,编辑时须保留;锁文件顶层键的顺序不影响该身份。库 wheel 解压到 site-packages,各 wheel 的 `.data/scripts` 目录保留辅助文件,不生成命令行包装器。其他安装方案会被拒绝。macOS 只对版本化 Python 可执行文件签名一次,在外部资源及安装副本中保留[两个解释器别名](../../packages/skill/tool-workspace-dependencies/README.zh.md)。POSIX 本机目标检查要求解释器别名为相对链接。所有本机目标检查验证命令名、子进程执行及包含 pip 的 venv,并在清理暂存目录后以及 macOS 签名后验证锁定 wheel 的集合与版本,允许解释器自带的 pip,并检查 Python 版本、Office 文档读写和依赖完整性,不写入字节码。独立 Node 可执行文件获得 V8 所需的 JIT 权限;x64 目标还获得 `com.apple.security.cs.allow-unsigned-executable-memory`,允许可执行代码页,包括在 Rosetta 下运行时。ARM64 Node 和 Office 辅助程序保留仅 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets//runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。 | 决策 | 原因 | 直接结果 | |---|---|---| diff --git a/apps/desktop/scripts/macos-runtime.ts b/apps/desktop/scripts/macos-runtime.ts index 3d5fabdbed..1d4c27c0d5 100644 --- a/apps/desktop/scripts/macos-runtime.ts +++ b/apps/desktop/scripts/macos-runtime.ts @@ -1,8 +1,9 @@ /** Sign final native runtime files before the enclosing Desktop application is signed. */ import { createHash } from 'node:crypto' -import { closeSync, openSync, readSync } from 'node:fs' -import { join } from 'node:path' +import { closeSync, openSync, readdirSync, readSync } from 'node:fs' +import { join, relative, sep } from 'node:path' +import { primaryRuntimePythonAliases, readPrimaryRuntime } from '../../../packages/skill/tool-workspace-dependencies/src/index.ts' import { inventoryDesktopRuntime } from '../src/runtime-tree.ts' import type { MacOSSigningEnvironment } from './desktop-release-environment.mjs' import { cachedMacOSSignature, pruneMacOSSignatureCache } from './macos-signature-cache.ts' @@ -19,19 +20,34 @@ function magic(path: string): string { } finally { closeSync(descriptor) } } +async function primaryRuntimeFiles(root: string): Promise { + const aliases = await primaryRuntimePythonAliases(root, await readPrimaryRuntime(root)) + const files: string[] = [] + for (const entry of readdirSync(root, { recursive: true, withFileTypes: true })) { + const path = relative(root, join(entry.parentPath, entry.name)).split(sep).join('/') + if (entry.isDirectory() || (entry.isSymbolicLink() && aliases.has(path))) continue + if (!entry.isFile()) throw new Error(`desktop runtime: unsupported filesystem entry ${path}`) + files.push(path) + } + return files.sort() +} + /** * Sign and verify every materialized Mach-O file, awaiting all signers on failure. - * @param root - Self-contained production runtime without symlinks. + * @param root - Self-contained production runtime. * @param appId - Release application identifier. * @param expected - Required signing identity. * @param arch - Target runtime architecture, independent of the signing host. * @param cacheDirectory - Optional content-addressed cache; requires the keychain-owned signing probe. + * @param kind - Primary payloads admit validated Python aliases; Desktop application runtimes reject every link. * @returns Number of signed native files. */ export async function signMacOSRuntime( root: string, appId: string, expected: MacOSSigningEnvironment, arch: 'arm64' | 'x64', cacheDirectory?: string, + kind: 'desktop' | 'primary' = 'desktop', ): Promise { - const files = inventoryDesktopRuntime(root).map(file => file.path).filter(path => MACH_O_MAGICS.has(magic(join(root, path)))) + const inventory = kind === 'primary' ? await primaryRuntimeFiles(root) : inventoryDesktopRuntime(root).map(file => file.path) + const files = inventory.filter(path => MACH_O_MAGICS.has(magic(join(root, path)))) const policy = cacheDirectory === undefined ? undefined : macOSCachePolicy(process.env.DSH_DESKTOP_MACOS_SIGNING_PROBE ?? '') let hits = 0 let misses = 0 diff --git a/apps/desktop/scripts/prepare-dsh.ts b/apps/desktop/scripts/prepare-dsh.ts index 442e01757e..c890f4a4f5 100644 --- a/apps/desktop/scripts/prepare-dsh.ts +++ b/apps/desktop/scripts/prepare-dsh.ts @@ -155,7 +155,7 @@ async function main(): Promise { } if (process.platform === 'darwin') { await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:dsh-native', () => signMacOSRuntime(DSH_OUTPUT_ROOT, resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), target.arch, join(BUILD_PATHS.root, 'signature-cache'))) - await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:primary-native', () => signMacOSRuntime(join(RUNTIME_ROOT, 'primary-runtime'), resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), target.arch, join(BUILD_PATHS.root, 'signature-cache'))) + await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:primary-native', () => signMacOSRuntime(join(RUNTIME_ROOT, 'primary-runtime'), resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), target.arch, join(BUILD_PATHS.root, 'signature-cache'), 'primary')) } 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'))) diff --git a/apps/desktop/tests/macos-runtime.spec.ts b/apps/desktop/tests/macos-runtime.spec.ts index fdcb61600a..3c89db0cc8 100644 --- a/apps/desktop/tests/macos-runtime.spec.ts +++ b/apps/desktop/tests/macos-runtime.spec.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' @@ -25,6 +25,37 @@ it('signs Mach-O files in their final locations and verifies each signature', as expect(signMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'addon.node'), expect.stringMatching(/^com\.example\.app\.runtime\.[a-f0-9]{64}$/u), identity, undefined) expect(verifyMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'addon.node'), identity) }) + +it.skipIf(process.platform === 'win32')('signs a shared Python interpreter once and retains both executable aliases', async () => { + const path = root() + const bin = join(path, 'dependencies/python/bin') + mkdirSync(bin, { recursive: true }) + writeFileSync(join(path, 'runtime.json'), JSON.stringify({ desktopVersion: '1', platform: 'darwin', arch: 'arm64', python: '3.12.14', pythonPackages: {} })) + writeFileSync(join(bin, 'python3.12'), Buffer.from('cffaedfe00000000', 'hex')) + for (const name of ['python', 'python3']) symlinkSync('python3.12', join(bin, name)) + await expect(signMacOSRuntime(path, 'com.example.app', identity, 'arm64', undefined, 'primary')).resolves.toBe(1) + expect(signMacOSRuntimeCode).toHaveBeenCalledExactlyOnceWith(join(bin, 'python3.12'), expect.any(String), identity, undefined) + for (const name of ['python', 'python3']) expect(readlinkSync(join(bin, name))).toBe('python3.12') + await expect(signMacOSRuntime(path, 'com.example.app', identity, 'arm64')).rejects.toThrow('unsupported filesystem entry') +}) + +it.skipIf(process.platform === 'win32').each(['unknown', 'absolute', 'escaping', 'dangling', 'chained', 'directory'])('rejects a %s runtime link before signing any file', async (kind) => { + const path = root() + const bin = join(path, 'dependencies/python/bin') + mkdirSync(bin, { recursive: true }) + writeFileSync(join(path, 'runtime.json'), JSON.stringify({ desktopVersion: '1', platform: 'darwin', arch: 'arm64', python: '3.12.14', pythonPackages: {} })) + const canonical = join(bin, 'python3.12') + if (kind === 'directory') mkdirSync(canonical) + else if (kind !== 'dangling') writeFileSync(canonical, Buffer.from('cffaedfe00000000', 'hex')) + if (kind === 'chained') { + rmSync(canonical) + symlinkSync('python3', canonical) + } + const target = kind === 'absolute' ? canonical : kind === 'escaping' ? '../outside' : 'python3.12' + symlinkSync(target, join(bin, kind === 'unknown' ? 'unlisted' : 'python3')) + await expect(signMacOSRuntime(path, 'com.example.app', identity, 'arm64', undefined, 'primary')).rejects.toThrow() + expect(signMacOSRuntimeCode).not.toHaveBeenCalled() +}) it('awaits other signers before rejecting and permitting output cleanup', async () => { const path = root() for (const name of ['a.node', 'b.node']) writeFileSync(join(path, name), Buffer.from('cffaedfe00000000', 'hex')) diff --git a/packages/skill/tool-workspace-dependencies/README.i18n.yaml b/packages/skill/tool-workspace-dependencies/README.i18n.yaml index 2855c549de..b08ed6bb9f 100644 --- a/packages/skill/tool-workspace-dependencies/README.i18n.yaml +++ b/packages/skill/tool-workspace-dependencies/README.i18n.yaml @@ -21,8 +21,8 @@ en: 97b1f99b4e9c2569 zh: bf6c6ce74073413f /deepseek-ai-dsh-tool-workspace-dependencies/use-this-package/payload-layout: - en: 0ba9c7e3b9738e4d - zh: a98344644ac55182 + en: 5d80c363997ffb43 + zh: f017f881e37c7bd1 /deepseek-ai-dsh-tool-workspace-dependencies/use-this-package/build-a-carrier-payload: en: 0f3e570525053f09 zh: 1b0cd3b653e1bd5d diff --git a/packages/skill/tool-workspace-dependencies/README.md b/packages/skill/tool-workspace-dependencies/README.md index 1f7147a1fe..d15b472557 100644 --- a/packages/skill/tool-workspace-dependencies/README.md +++ b/packages/skill/tool-workspace-dependencies/README.md @@ -44,6 +44,8 @@ Mount the plugin beside the tool registry with the payload directory. Configurat `runtime.json` records `desktopVersion`, `platform` (`win32`, `darwin`, or `linux`), `arch`, optional `payloadDigest`, top-level `python`, optional `node`/`pnpm` versions, and the complete `pythonPackages` distribution-version map. A pnpm entry requires Node.js. Python libraries, including numpy and pandas, appear only in `pythonPackages`. Entries live under `dependencies/`: `python/bin/python3` (`python/python.exe` on Windows) with `site-packages` beneath it, and, when declared, `node/bin/node` with `node/node_modules` and `pnpm/bin/pnpm.mjs`. A manifest whose platform or architecture differs from the running process is rejected. +On macOS and Linux, the builder retains `python` and `python3` as relative links to the sibling versioned interpreter (for example, `python3.12`). Installation preserves these two aliases and materializes other links. Alias validation requires the exact sibling filename and a regular target file; legacy payloads with regular command files remain supported. Windows keeps its ordinary `python.exe` file. + The packaged `sdk` profile uses its bundled Python and Office skills by default; `DSH_PRIMARY_RUNTIME` overrides the resource location, and an empty value opts out. Source launches without a carrier default remain opt-in. See [runtime configuration](../../../python/sdk-runtime/README.md) for independent skill selection. Missing skill resources produce a startup warning; invalid or incomplete external runtime payloads fail the first tool call. Profile configuration changes require restarting the SDK process. ### Build a carrier payload diff --git a/packages/skill/tool-workspace-dependencies/README.zh.md b/packages/skill/tool-workspace-dependencies/README.zh.md index f2a1934256..47dda72d9f 100644 --- a/packages/skill/tool-workspace-dependencies/README.zh.md +++ b/packages/skill/tool-workspace-dependencies/README.zh.md @@ -44,6 +44,8 @@ kind: "package-reference" `runtime.json` 记录 `desktopVersion`、`platform`(`win32`、`darwin` 或 `linux`)、`arch`、可选的 `payloadDigest`、顶层 `python` 与可选的 `node`/`pnpm` 版本,以及完整的 `pythonPackages` 分发包版本表。声明 pnpm 时必须同时声明 Node.js。numpy、pandas 等 Python 库只出现在 `pythonPackages` 中。条目位于 `dependencies/`:`python/bin/python3`(Windows 为 `python/python.exe`)及其下的 `site-packages`;声明了才有的 `node/bin/node`、`node/node_modules` 与 `pnpm/bin/pnpm.mjs`。平台或架构与当前进程不符的清单被拒绝。 +在 macOS 和 Linux 上,构建器将 `python` 和 `python3` 保留为指向同目录版本化解释器(例如 `python3.12`)的相对链接。安装时保留这两个别名,其他链接展开为实际文件。别名校验要求目标名称与同目录解释器文件名完全一致,且目标为普通文件;命令入口为普通文件的旧 payload 仍受支持。Windows 保留普通的 `python.exe` 文件。 + 打包的 `sdk` profile 默认使用随包 Python 和 Office skills;`DSH_PRIMARY_RUNTIME` 覆盖资源位置,空值表示禁用。没有载体默认路径的源码启动仍需显式启用。独立选择 skills 的方式见[运行时配置](../../../python/sdk-runtime/README.zh.md)。缺少 skill 资源会产生启动警告;无效或不完整的外部运行时 payload 在首次工具调用时失败。profile 配置变更需要重启 SDK 进程。 ### 构建载体 payload diff --git a/packages/skill/tool-workspace-dependencies/src/index.ts b/packages/skill/tool-workspace-dependencies/src/index.ts index 973a5220b8..683cf932df 100644 --- a/packages/skill/tool-workspace-dependencies/src/index.ts +++ b/packages/skill/tool-workspace-dependencies/src/index.ts @@ -1,6 +1,6 @@ /** Model-facing query for a bundled Python, Node.js, and pnpm payload, in place or installed under the Harness home. */ -import { cp, lstat, mkdir, mkdtemp, readFile, rename, rm, stat } from 'node:fs/promises' +import { cp, lstat, mkdir, mkdtemp, readFile, readlink, rename, rm, stat, symlink } from 'node:fs/promises' import { dirname, isAbsolute, join } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' @@ -132,6 +132,48 @@ export async function readPrimaryRuntime(root: string): Promise> { + const aliases = new Map() + if (manifest.platform === 'win32') return aliases + const interpreter = `python${manifest.python.split('.').slice(0, 2).join('.')}` + for (const name of ['python', 'python3']) { + const path = `dependencies/python/bin/${name}` + let entry + try { entry = await lstat(join(root, path)) } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue + throw error + } + if (!entry.isSymbolicLink()) continue + if (await readlink(join(root, path)) !== interpreter + || !(await lstat(join(root, 'dependencies/python/bin', interpreter))).isFile()) { + throw new Error(`primary runtime: invalid Python alias ${path}`) + } + aliases.set(path, interpreter) + } + return aliases +} + +/** + * Copy a payload, retaining only validated Python interpreter aliases as relative links. + * @param source - Payload directory; all other links are materialized. + * @param destination - Empty destination owned by the caller. + * @param manifest - Validated source metadata. + * @returns Resolves after every copied file and interpreter alias is available independently of the source. + */ +export async function copyPrimaryRuntime(source: string, destination: string, manifest: PrimaryRuntimeManifest): Promise { + const aliases = await primaryRuntimePythonAliases(source, manifest) + const excluded = new Set([...aliases.keys()].map(path => join(source, path))) + await cp(source, destination, { recursive: true, dereference: true, filter: path => !excluded.has(path) }) + for (const [path, target] of aliases) await symlink(target, join(destination, path)) +} + /** * Resolve platform-specific interpreter and library locations without changing the environment. * @param root - Absolute payload or installation directory. @@ -190,7 +232,9 @@ async function compatibleManifest(source: string): Promise { - const paths = workspaceDependencyPaths(source, await compatibleManifest(source)) + const manifest = await compatibleManifest(source) + await primaryRuntimePythonAliases(source, manifest) + const paths = workspaceDependencyPaths(source, manifest) await validatePayloadEntries(paths) return paths } @@ -209,13 +253,14 @@ export async function installPrimaryRuntime(source: string, root: string): Promi const rootExists = await exists(root) if (!rootExists && previousExists) await rename(previous, root) if (await exists(join(root, 'runtime.json')) && JSON.stringify(await readPrimaryRuntime(root)) === JSON.stringify(manifest)) { + await primaryRuntimePythonAliases(root, manifest) const paths = workspaceDependencyPaths(root, manifest) await validatePayloadEntries(paths) return paths } const staging = await mkdtemp(join(dirname(root), '.primary-runtime-')) try { - await cp(source, staging, { recursive: true, dereference: true }) + await copyPrimaryRuntime(source, staging, manifest) const paths = workspaceDependencyPaths(staging, manifest) await validatePayloadEntries(paths) await rm(previous, { recursive: true, force: true }) diff --git a/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts b/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts index 57f1eb1c15..ff2a15d90c 100644 --- a/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts +++ b/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts @@ -1,5 +1,5 @@ import * as fs from 'node:fs/promises' -import { mkdir, mkdtemp, readFile, rename, rm, symlink, writeFile } from 'node:fs/promises' +import { lstat, mkdir, mkdtemp, readFile, readlink, rename, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { pathToFileURL } from 'node:url' @@ -13,7 +13,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import { ToolCallId } from '@deepseek-ai/dsh-llm' import * as workspaceDependencies from '../src/index.ts' -import { installPrimaryRuntime, parsePrimaryRuntime, readPrimaryRuntime, resolvePrimaryRuntime, workspaceDependencyPaths, type PrimaryRuntimeManifest } from '../src/index.ts' +import { copyPrimaryRuntime, installPrimaryRuntime, parsePrimaryRuntime, readPrimaryRuntime, resolvePrimaryRuntime, workspaceDependencyPaths, type PrimaryRuntimeManifest } from '../src/index.ts' vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal() @@ -67,6 +67,56 @@ it('installs offline, reuses the same release, and leaves environment and user p expect(process.env).toEqual(environment) }) +it.skipIf(process.platform === 'win32')('upgrades expanded Python commands to relocatable aliases and reuses the installed payload', async () => { + const { source, root, manifest, directory } = await fixture() + await installPrimaryRuntime(source, root) + const bin = dirname(workspaceDependencyPaths(source, manifest).python) + await rename(join(bin, 'python3'), join(bin, 'python3.12')) + for (const name of ['python', 'python3']) await symlink('python3.12', join(bin, name)) + await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, payloadDigest: 'a'.repeat(64) })) + const installed = await installPrimaryRuntime(source, root) + await writeFile(join(installed.pythonPackages, 'user.py'), 'user package') + expect(await installPrimaryRuntime(source, root)).toEqual(installed) + expect(await readFile(join(installed.pythonPackages, 'user.py'), 'utf8')).toBe('user package') + const moved = join(directory, 'moved runtime with spaces') + await rename(root, moved) + await rm(source, { recursive: true }) + const relocatedBin = dirname((await resolvePrimaryRuntime(moved)).python) + expect((await lstat(join(relocatedBin, 'python3.12'))).isFile()).toBe(true) + for (const name of ['python', 'python3']) { + expect(await readlink(join(relocatedBin, name))).toBe('python3.12') + expect(await readFile(join(relocatedBin, name), 'utf8')).toBe('interpreter') + } +}) + +it.skipIf(process.platform === 'win32').each(['copy', 'reuse', 'in-place'] as const)('rejects escaping Python aliases during %s preparation', async (mode) => { + const { source, root, manifest } = await fixture() + if (mode === 'reuse') await installPrimaryRuntime(source, root) + const payload = mode === 'reuse' ? root : source + const python = workspaceDependencyPaths(payload, manifest).python + await rename(python, join(dirname(dirname(python)), 'outside-python')) + await symlink('../outside-python', python) + const operation = mode === 'in-place' ? resolvePrimaryRuntime(source) : installPrimaryRuntime(source, root) + await expect(operation).rejects.toThrow('invalid Python alias') +}) + +it('copies Windows payloads without creating interpreter aliases', async () => { + const { source, root, manifest } = await fixture() + const windows = { ...manifest, platform: 'win32' } + const python = workspaceDependencyPaths(source, windows).python + await writeFile(python, 'Windows interpreter') + await copyPrimaryRuntime(source, root, windows) + expect(await readFile(workspaceDependencyPaths(root, windows).python, 'utf8')).toBe('Windows interpreter') + expect((await lstat(workspaceDependencyPaths(root, windows).python)).isFile()).toBe(true) +}) + +it.skipIf(process.platform === 'win32')('propagates an unreadable alias without creating a partial payload', async () => { + const { source, root, manifest } = await fixture() + vi.mocked(fs.lstat).mockRejectedValueOnce(Object.assign(new Error('alias denied'), { code: 'EACCES' })) + await expect(copyPrimaryRuntime(source, root, manifest)).rejects.toThrow('alias denied') + await expect(readFile(join(root, 'runtime.json'))).rejects.toMatchObject({ code: 'ENOENT' }) +}) + it('replaces release components and recovers an interrupted directory swap', async () => { const { source, root, manifest } = await fixture() await installPrimaryRuntime(source, root) diff --git a/scripts/primary-runtime/prepare.spec.ts b/scripts/primary-runtime/prepare.spec.ts index 6f41ac46b4..0e8d02d128 100644 --- a/scripts/primary-runtime/prepare.spec.ts +++ b/scripts/primary-runtime/prepare.spec.ts @@ -55,6 +55,17 @@ it('reports missing distribution metadata before trying to execute a stale nativ } finally { await rm(root, { recursive: true, force: true }) } }) +it.skipIf(process.platform === 'win32')('rejects expanded interpreter aliases in the native payload smoke', async () => { + const root = await mkdtemp(join(tmpdir(), 'primary-expanded-aliases-')) + try { + await writeFile(join(root, 'runtime.json'), JSON.stringify({ desktopVersion: '1', platform: process.platform, arch: process.arch, + python: '3.12.14', pythonPackages: { numpy: '2.3.5' } })) + await mkdir(join(root, 'dependencies/python/bin'), { recursive: true }) + for (const name of ['python', 'python3', 'python3.12']) await writeFile(join(root, 'dependencies/python/bin', name), 'interpreter') + expect(() => { smokePrimaryRuntime(root) }).toThrow('Python commands must be relative aliases') + } finally { await rm(root, { recursive: true, force: true }) } +}) + it('extracts a hash-verified cached library without a Python installer or network request', async () => { const root = await mkdtemp(join(tmpdir(), 'desktop-wheel-')) try { diff --git a/scripts/primary-runtime/prepare.ts b/scripts/primary-runtime/prepare.ts index a156a7564a..efb3bb57db 100644 --- a/scripts/primary-runtime/prepare.ts +++ b/scripts/primary-runtime/prepare.ts @@ -2,7 +2,7 @@ import { execFileSync } from 'node:child_process' import { createHash } from 'node:crypto' -import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { cpSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, writeFileSync } from 'node:fs' import { cp } from 'node:fs/promises' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' @@ -10,7 +10,7 @@ import { dirname, join, resolve } from 'node:path' import { parseArgs } from 'node:util' import extractZip from 'extract-zip' import { x as extractTar } from 'tar' -import { parsePrimaryRuntime, workspaceDependencyPaths, type PrimaryRuntimeManifest } from '../../packages/skill/tool-workspace-dependencies/src/index.ts' +import { copyPrimaryRuntime, parsePrimaryRuntime, workspaceDependencyPaths, type PrimaryRuntimeManifest } from '../../packages/skill/tool-workspace-dependencies/src/index.ts' import lock from './lock.json' with { type: 'json' } /** @@ -54,7 +54,7 @@ export function primaryRuntimePayloadDigest( // Identity preserves key order within the selected target, wheel records and distribution map, plus wheel-entry order. // Bump format when extraction or assembly changes payload bytes without changing locked inputs. return createHash('sha256').update(JSON.stringify({ - format: 4, target, pythonVersion, pythonRelease, nodeVersion: pnpmVersion === undefined ? undefined : nodeVersion, + format: 5, target, pythonVersion, pythonRelease, nodeVersion: pnpmVersion === undefined ? undefined : nodeVersion, artifact: runtimeLock.targets[target], wheels, pythonPackages, pnpm: pnpmVersion, })).digest('hex') } @@ -158,7 +158,7 @@ export async function preparePrimaryRuntime(options: PreparePrimaryRuntimeOption writeFileSync(join(output, 'runtime.json'), `${JSON.stringify(manifest, undefined, 2)}\n`) const destination = join(paths.runtime, 'primary-runtime') rmSync(destination, { recursive: true, force: true }) - await cp(output, destination, { recursive: true, dereference: true }) + await copyPrimaryRuntime(output, destination, manifest) } finally { rmSync(staging, { recursive: true, force: true }) } @@ -179,6 +179,16 @@ export function smokePrimaryRuntime(root: string, environment: NodeJS.ProcessEnv if (manifest.platform !== process.platform || manifest.arch !== process.arch) return if (Object.keys(manifest.pythonPackages).length === 0) throw new Error('primary runtime: missing Python distribution versions; prepare the payload before running its smoke checks.') const entries = workspaceDependencyPaths(root, manifest) + if (manifest.platform !== 'win32') { + const interpreter = `python${manifest.python.split('.').slice(0, 2).join('.')}` + for (const name of ['python', 'python3']) { + const path = join(dirname(entries.python), name) + if (!lstatSync(path).isSymbolicLink() || readlinkSync(path) !== interpreter) { + throw new Error(`primary runtime: Python commands must be relative aliases of ${interpreter}`) + } + } + if (!lstatSync(join(dirname(entries.python), interpreter)).isFile()) throw new Error('primary runtime: versioned Python must be a regular file') + } const options = { stdio: 'inherit', timeout: 120_000, env: environment } as const execFileSync(entries.python, ['-I', '-B', '-c', 'import decimal, xml.parsers.expat, lzma, uuid, numpy, pandas; assert numpy.arange(4).sum() == 6; assert pandas.DataFrame({"n": [1, 2]}).n.sum() == 3'], options) execFileSync(entries.python, ['-I', '-B', join(import.meta.dirname, 'smoke.py'), JSON.stringify(manifest.pythonPackages), diff --git a/scripts/primary-runtime/smoke.py b/scripts/primary-runtime/smoke.py index 232d67e108..a1997c0ac1 100644 --- a/scripts/primary-runtime/smoke.py +++ b/scripts/primary-runtime/smoke.py @@ -21,9 +21,31 @@ from pptx.enum.chart import XL_CHART_TYPE from pptx.util import Inches +def check_interpreters(): + """Exercise command aliases, child execution and a pip-enabled virtual environment.""" + executable = Path(sys.executable) + names = [executable.name] if sys.platform == "win32" else ["python", "python3", f"python{sys.version_info.major}.{sys.version_info.minor}"] + for name in names: + interpreter = executable.with_name(name) + subprocess.run([ + str(interpreter), "-I", "-B", "-c", + "import subprocess, sys; from pathlib import Path; " + "assert Path(sys.executable) == Path(sys.argv[1]); " + "subprocess.run([sys.executable, '-I', '-B', '-c', 'import ssl, sqlite3, ctypes'], check=True, timeout=30)", + str(interpreter), + ], check=True, timeout=60) + with tempfile.TemporaryDirectory(prefix="dsh python venv ") as directory: + environment = Path(directory) / "environment" + subprocess.run([str(executable.with_name(names[0])), "-I", "-B", "-m", "venv", str(environment)], check=True, timeout=60) + python = environment / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python") + subprocess.run([str(python), "-I", "-B", "-c", "import sys; assert sys.prefix != sys.base_prefix"], check=True, timeout=30) + subprocess.run([str(python), "-I", "-B", "-m", "pip", "check"], check=True, timeout=30) + + def main(): """Check installed versions and read back editable Office documents.""" assert sys.version_info[:3] == tuple(map(int, sys.argv[2].split("."))), sys.version + check_interpreters() versions = json.loads(sys.argv[1]) # Only pip belongs to the interpreter baseline; all other distributions must be declared. # Each target's native release-host smoke must confirm this baseline. From 3c95edd4688bbd380c910faace18ea3721a398f9 Mon Sep 17 00:00:00 2001 From: kingwl Date: Wed, 30 Sep 2026 14:43:28 +0800 Subject: [PATCH 2/2] test: cover Python runtime aliases on Windows hosts --- apps/desktop/tests/macos-runtime.spec.ts | 2 + .../tests/primary-runtime-copy.spec.ts | 87 +++++++++++++++++++ .../tests/tool-workspace-dependencies.spec.ts | 6 +- scripts/primary-runtime/prepare.spec.ts | 1 + 4 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 packages/skill/tool-workspace-dependencies/tests/primary-runtime-copy.spec.ts diff --git a/apps/desktop/tests/macos-runtime.spec.ts b/apps/desktop/tests/macos-runtime.spec.ts index 3c89db0cc8..a354f8d9e3 100644 --- a/apps/desktop/tests/macos-runtime.spec.ts +++ b/apps/desktop/tests/macos-runtime.spec.ts @@ -26,6 +26,7 @@ it('signs Mach-O files in their final locations and verifies each signature', as expect(verifyMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'addon.node'), identity) }) +// File symlinks in these macOS fixtures require privileges unavailable to ordinary Windows processes. it.skipIf(process.platform === 'win32')('signs a shared Python interpreter once and retains both executable aliases', async () => { const path = root() const bin = join(path, 'dependencies/python/bin') @@ -39,6 +40,7 @@ it.skipIf(process.platform === 'win32')('signs a shared Python interpreter once await expect(signMacOSRuntime(path, 'com.example.app', identity, 'arm64')).rejects.toThrow('unsupported filesystem entry') }) +// The signer must inspect real file symlinks, including dangling and cyclic ones. it.skipIf(process.platform === 'win32').each(['unknown', 'absolute', 'escaping', 'dangling', 'chained', 'directory'])('rejects a %s runtime link before signing any file', async (kind) => { const path = root() const bin = join(path, 'dependencies/python/bin') diff --git a/packages/skill/tool-workspace-dependencies/tests/primary-runtime-copy.spec.ts b/packages/skill/tool-workspace-dependencies/tests/primary-runtime-copy.spec.ts new file mode 100644 index 0000000000..03f2ee0f70 --- /dev/null +++ b/packages/skill/tool-workspace-dependencies/tests/primary-runtime-copy.spec.ts @@ -0,0 +1,87 @@ +/** Target-specific payload copying with filesystem link operations modeled independently of host privileges. */ + +import * as fs from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { copyPrimaryRuntime, type PrimaryRuntimeManifest } from '../src/index.ts' + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, lstat: vi.fn(actual.lstat), readlink: vi.fn(actual.readlink), symlink: vi.fn(actual.symlink) } +}) + +const roots: string[] = [] +afterEach(async () => { + vi.resetAllMocks() + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) + +async function fixture(platform: string) { + const root = await mkdtemp(join(tmpdir(), 'primary-runtime-copy-')) + roots.push(root) + const source = join(root, 'source') + const destination = join(root, 'destination') + const bin = join(source, 'dependencies/python', ...(platform === 'win32' ? [] : ['bin'])) + await mkdir(bin, { recursive: true }) + const executable = platform === 'win32' ? 'python.exe' : 'python3.12' + await writeFile(join(bin, executable), 'interpreter bytes') + const manifest: PrimaryRuntimeManifest = { desktopVersion: '1', platform, arch: 'x64', python: '3.12.14', pythonPackages: {} } + return { source, destination, bin, executable, manifest } +} + +async function aliases(bin: string, target: string) { + const actual = await vi.importActual('node:fs/promises') + const paths = ['python', 'python3'].map(name => join(bin, name)) + for (const path of paths) await writeFile(path, 'uncopied alias placeholder') + vi.mocked(fs.lstat).mockImplementation(async (...args) => { + const entry = await actual.lstat(...args) + return typeof args[0] === 'string' && paths.includes(args[0]) + ? Object.assign(entry, { isSymbolicLink: () => true }) : entry + }) + vi.mocked(fs.readlink).mockImplementation(async (path) => { + if (typeof path === 'string' && paths.includes(path)) return target + return actual.readlink(path) + }) + // Exclusive writes expose accidental alias copies while recording the requested link text without OS symlink privileges. + vi.mocked(fs.symlink).mockImplementation(async (target, path) => { await writeFile(path, String(target), { flag: 'wx' }) }) +} + +it.each(['linux', 'darwin', 'win32'])('copies a %s payload using its declared layout', async (platform) => { + const { source, destination, bin, executable, manifest } = await fixture(platform) + if (platform !== 'win32') await aliases(bin, executable) + await copyPrimaryRuntime(source, destination, manifest) + await rm(source, { recursive: true }) + const installedBin = join(destination, 'dependencies/python', ...(platform === 'win32' ? [] : ['bin'])) + expect(await readFile(join(installedBin, executable), 'utf8')).toBe('interpreter bytes') + if (platform === 'win32') expect(fs.symlink).not.toHaveBeenCalled() + else { + expect(fs.symlink).toHaveBeenCalledTimes(2) + for (const name of ['python', 'python3']) { + expect(fs.symlink).toHaveBeenCalledWith('python3.12', join(installedBin, name)) + expect(await readFile(join(installedBin, name), 'utf8')).toBe('python3.12') + } + } +}) + +it.each(['absolute', 'escaping', 'chained', 'dangling', 'directory'])('rejects a %s alias before copying the payload', async (kind) => { + const { source, destination, bin, executable, manifest } = await fixture('linux') + const target = kind === 'absolute' ? join(bin, executable) : kind === 'escaping' ? '../outside' : kind === 'chained' ? 'python3' : executable + await aliases(bin, target) + if (kind === 'dangling' || kind === 'directory') await rm(join(bin, executable)) + if (kind === 'directory') await mkdir(join(bin, executable)) + if (kind === 'dangling') await expect(copyPrimaryRuntime(source, destination, manifest)).rejects.toMatchObject({ code: 'ENOENT' }) + else await expect(copyPrimaryRuntime(source, destination, manifest)).rejects.toThrow('invalid Python alias') + await expect(fs.lstat(destination)).rejects.toMatchObject({ code: 'ENOENT' }) + expect(fs.symlink).not.toHaveBeenCalled() +}) + +it('copies legacy regular Python commands without requiring an optional alias', async () => { + const { source, destination, bin, manifest } = await fixture('linux') + await writeFile(join(bin, 'python3'), 'legacy interpreter') + await copyPrimaryRuntime(source, destination, manifest) + expect(await readFile(join(destination, 'dependencies/python/bin/python3'), 'utf8')).toBe('legacy interpreter') + await expect(readFile(join(destination, 'dependencies/python/bin/python'))).rejects.toMatchObject({ code: 'ENOENT' }) + expect(fs.symlink).not.toHaveBeenCalled() +}) diff --git a/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts b/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts index ff2a15d90c..c9418f1da1 100644 --- a/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts +++ b/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts @@ -67,6 +67,7 @@ it('installs offline, reuses the same release, and leaves environment and user p expect(process.env).toEqual(environment) }) +// Windows installations use python.exe; this upgrade needs the native POSIX alias layout. it.skipIf(process.platform === 'win32')('upgrades expanded Python commands to relocatable aliases and reuses the installed payload', async () => { const { source, root, manifest, directory } = await fixture() await installPrimaryRuntime(source, root) @@ -89,6 +90,7 @@ it.skipIf(process.platform === 'win32')('upgrades expanded Python commands to re } }) +// These entry-point checks install real POSIX aliases; Windows payloads have no such entries. it.skipIf(process.platform === 'win32').each(['copy', 'reuse', 'in-place'] as const)('rejects escaping Python aliases during %s preparation', async (mode) => { const { source, root, manifest } = await fixture() if (mode === 'reuse') await installPrimaryRuntime(source, root) @@ -110,10 +112,10 @@ it('copies Windows payloads without creating interpreter aliases', async () => { expect((await lstat(workspaceDependencyPaths(root, windows).python)).isFile()).toBe(true) }) -it.skipIf(process.platform === 'win32')('propagates an unreadable alias without creating a partial payload', async () => { +it('propagates an unreadable alias without creating a partial payload', async () => { const { source, root, manifest } = await fixture() vi.mocked(fs.lstat).mockRejectedValueOnce(Object.assign(new Error('alias denied'), { code: 'EACCES' })) - await expect(copyPrimaryRuntime(source, root, manifest)).rejects.toThrow('alias denied') + await expect(copyPrimaryRuntime(source, root, { ...manifest, platform: 'linux' })).rejects.toThrow('alias denied') await expect(readFile(join(root, 'runtime.json'))).rejects.toMatchObject({ code: 'ENOENT' }) }) diff --git a/scripts/primary-runtime/prepare.spec.ts b/scripts/primary-runtime/prepare.spec.ts index 0e8d02d128..f00804283c 100644 --- a/scripts/primary-runtime/prepare.spec.ts +++ b/scripts/primary-runtime/prepare.spec.ts @@ -55,6 +55,7 @@ it('reports missing distribution metadata before trying to execute a stale nativ } finally { await rm(root, { recursive: true, force: true }) } }) +// Native Windows payloads require python.exe rather than POSIX interpreter aliases. it.skipIf(process.platform === 'win32')('rejects expanded interpreter aliases in the native payload smoke', async () => { const root = await mkdtemp(join(tmpdir(), 'primary-expanded-aliases-')) try {