From ca0b21661effed66f71b044f64e1e697d406db1f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:39:18 +0800 Subject: [PATCH] feat(python-runtime): package the Windows x64 dsh executable Add node24-win-x64 as the only supported Windows runtime target and publish it as a py3-none-win_amd64 wheel containing the conventional dsh and ripgrep .exe payload names. Keep Windows ARM64 rejected explicitly so Python cannot claim a carrier that CI and release automation do not build. Teach the pkg builder to require a native x64 Windows host, validate both node-pty ConPTY addons, copy @vscode's win32 ripgrep executable, and recognize pkg's .exe output. Extend runtime resolution, wheel staging, payload validation, and the preset closure check so the Windows-specific PowerShell plugins and sidecars fail loud when omitted. The sidecar resolver now maps a packaged main.exe to main-rg.exe; focused TypeScript and Python tests cover that name, the win_amd64 manifest, x64-only host selection, complete wheel payload, ConPTY inventory, and platform-conditioned plugin closure. --- packages/fs/tool-fs-search/src/search-core.ts | 7 +- .../tool-fs-search/tests/rg-sidecar.spec.ts | 29 +++++- pnpm-lock.yaml | 6 ++ python/sdk-runtime/hatch_build.py | 19 +++- python/sdk-runtime/package.json | 2 + python/sdk-runtime/platforms.json | 4 + .../src/deepseek_harness_runtime/__init__.py | 22 +++-- python/sdk/src/deepseek_harness/client.py | 19 +++- python/sdk/tests/test_client.py | 37 ++++++++ python/sdk/tests/test_release_version.py | 19 +++- python/sdk/tests/test_runtime_resolution.py | 24 +++++ ...uild-exe-for-python-sdk-native-pty.spec.ts | 20 +++- .../build-exe-for-python-sdk-native-pty.ts | 22 +++++ scripts/build-exe-for-python-sdk.spec.ts | 81 ++++++++++++++++ scripts/build-exe-for-python-sdk.ts | 92 +++++++++++++++---- scripts/build-python-release.py | 18 ++-- scripts/verify-runtime-closure.spec.ts | 11 ++- scripts/verify-runtime-closure.ts | 1 + 18 files changed, 380 insertions(+), 53 deletions(-) create mode 100644 scripts/build-exe-for-python-sdk.spec.ts diff --git a/packages/fs/tool-fs-search/src/search-core.ts b/packages/fs/tool-fs-search/src/search-core.ts index 5ac5521033..60ea042d4f 100644 --- a/packages/fs/tool-fs-search/src/search-core.ts +++ b/packages/fs/tool-fs-search/src/search-core.ts @@ -20,7 +20,7 @@ */ import { existsSync } from 'node:fs' -import { isAbsolute, relative, sep } from 'node:path' +import { isAbsolute, join, parse, relative, sep } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' import { ItemRetainer, TextRetainer } from '@deepseek-ai/dsh-output-retention' @@ -170,7 +170,10 @@ let rgPathPromise: Promise | undefined */ export function resolveRgPath(): Promise { rgPathPromise ??= Promise.resolve().then(async () => { - const executableSidecar = `${process.execPath}-rg` + const executable = parse(process.execPath) + const executableSidecar = process.platform === 'win32' + ? join(executable.dir, `${executable.name}-rg.exe`) + : `${process.execPath}-rg` if ('pkg' in process && existsSync(executableSidecar)) return executableSidecar return (await import('@vscode/ripgrep')).rgPath }) diff --git a/packages/fs/tool-fs-search/tests/rg-sidecar.spec.ts b/packages/fs/tool-fs-search/tests/rg-sidecar.spec.ts index 53c7a01aea..d3a6b5e188 100644 --- a/packages/fs/tool-fs-search/tests/rg-sidecar.spec.ts +++ b/packages/fs/tool-fs-search/tests/rg-sidecar.spec.ts @@ -1,9 +1,12 @@ +import { join, parse } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { dependencyRgPath, existsSync } = vi.hoisted(() => ({ dependencyRgPath: '/node_modules/@vscode/ripgrep/bin/rg', existsSync: vi.fn(), })) +const originalPlatform = process.platform +const originalExecPath = process.execPath vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal() @@ -16,17 +19,35 @@ beforeEach(() => { vi.resetModules() existsSync.mockReset() Reflect.deleteProperty(process, 'pkg') + Reflect.defineProperty(process, 'platform', { configurable: true, enumerable: true, value: originalPlatform }) + process.execPath = originalExecPath }) afterEach(() => { Reflect.deleteProperty(process, 'pkg') + Reflect.defineProperty(process, 'platform', { configurable: true, enumerable: true, value: originalPlatform }) + process.execPath = originalExecPath }) describe('ripgrep resolution', () => { it('uses the native sidecar beside the current executable', async () => { Reflect.defineProperty(process, 'pkg', { configurable: true, value: {} }) + Reflect.defineProperty(process, 'platform', { configurable: true, enumerable: true, value: 'linux' }) + process.execPath = '/runtime/dsh' existsSync.mockReturnValue(true) - const sidecar = `${process.execPath}-rg` + const sidecar = '/runtime/dsh-rg' + const { resolveRgPath } = await import('@deepseek-ai/dsh-tool-fs-search') + + await expect(resolveRgPath()).resolves.toBe(sidecar) + expect(existsSync).toHaveBeenCalledWith(sidecar) + }) + + it('uses a conventional executable name for the Windows ripgrep sidecar', async () => { + Reflect.defineProperty(process, 'pkg', { configurable: true, value: {} }) + Reflect.defineProperty(process, 'platform', { configurable: true, enumerable: true, value: 'win32' }) + process.execPath = 'C:\\runtime\\deepseek-harness-sdk-runtime-win-x64.exe' + existsSync.mockReturnValue(true) + const sidecar = 'C:\\runtime\\deepseek-harness-sdk-runtime-win-x64-rg.exe' const { resolveRgPath } = await import('@deepseek-ai/dsh-tool-fs-search') await expect(resolveRgPath()).resolves.toBe(sidecar) @@ -47,6 +68,10 @@ describe('ripgrep resolution', () => { const { resolveRgPath } = await import('@deepseek-ai/dsh-tool-fs-search') await expect(resolveRgPath()).resolves.toBe(dependencyRgPath) - expect(existsSync).toHaveBeenCalledWith(`${process.execPath}-rg`) + const executable = parse(process.execPath) + const sidecar = process.platform === 'win32' + ? join(executable.dir, `${executable.name}-rg.exe`) + : `${process.execPath}-rg` + expect(existsSync).toHaveBeenCalledWith(sidecar) }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6878a775b..f7f70486da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9972,6 +9972,12 @@ importers: '@deepseek-ai/dsh-tool-jobs': specifier: workspace:^ version: link:../../packages/jobs/tool-jobs + '@deepseek-ai/dsh-tool-pwsh': + specifier: workspace:^ + version: link:../../packages/shell/tool-pwsh + '@deepseek-ai/dsh-tool-pwsh-persistent': + specifier: workspace:^ + version: link:../../packages/shell/tool-pwsh-persistent '@deepseek-ai/dsh-tool-ralph': specifier: workspace:^ version: link:../../packages/workflow/tool-ralph diff --git a/python/sdk-runtime/hatch_build.py b/python/sdk-runtime/hatch_build.py index c4083387a6..22d0457d86 100644 --- a/python/sdk-runtime/hatch_build.py +++ b/python/sdk-runtime/hatch_build.py @@ -39,7 +39,15 @@ def _host_platform_tag() -> str: machine = platform.machine().lower() arch = "arm64" if machine in {"arm64", "aarch64"} else "x64" if machine in {"x86_64", "amd64"} else machine system = platform.system().lower() - key = f"macos-{arch}" if system == "darwin" else f"linux-{arch}" if system == "linux" else system + key = ( + f"macos-{arch}" + if system == "darwin" + else f"linux-{arch}" + if system == "linux" + else f"win-{arch}" + if system == "windows" + else system + ) try: return _PLATFORMS[key][0] except KeyError as exc: @@ -69,16 +77,21 @@ class RuntimeBuildHook(BuildHookInterface): runtime_files = sorted( runtime_dir.glob("deepseek-harness-sdk-runtime-*") if runtime_dir.is_dir() else [] ) - expected_files = [expected_executable, f"{expected_executable}-rg"] + expected_files = ( + [expected_executable, f"{expected_executable.removesuffix('.exe')}-rg.exe"] + if expected_executable.endswith(".exe") + else [expected_executable, f"{expected_executable}-rg"] + ) if "-macos-" in expected_executable: expected_files.append(f"{expected_executable}-spawn-helper") + expected_files.sort() found_files = [path.name for path in runtime_files] if found_files != expected_files: raise RuntimeError( f"runtime wheel {platform_tag} payload must be {expected_files}; found {found_files}" ) for executable in runtime_files: - if executable.stat().st_mode & stat.S_IXUSR == 0: + if platform_tag != "win_amd64" and executable.stat().st_mode & stat.S_IXUSR == 0: raise RuntimeError(f"runtime executable is not executable: {executable}") build_data["pure_python"] = False build_data["infer_tag"] = False diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index d5684867e6..331d388a0f 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -59,6 +59,7 @@ "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-persona": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", + "@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:^", "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-terminal-bash": "workspace:^", "@deepseek-ai/dsh-repeat-tool-reminder": "workspace:^", @@ -103,6 +104,7 @@ "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-fs-search": "workspace:^", "@deepseek-ai/dsh-tool-goal": "workspace:^", + "@deepseek-ai/dsh-tool-pwsh": "workspace:^", "@deepseek-ai/dsh-tool-ralph": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", diff --git a/python/sdk-runtime/platforms.json b/python/sdk-runtime/platforms.json index e65cd6a735..9c0a1fec72 100644 --- a/python/sdk-runtime/platforms.json +++ b/python/sdk-runtime/platforms.json @@ -10,5 +10,9 @@ "macos-arm64": { "tag": "macosx_14_0_arm64", "executable": "deepseek-harness-sdk-runtime-macos-arm64" + }, + "win-x64": { + "tag": "win_amd64", + "executable": "deepseek-harness-sdk-runtime-win-x64.exe" } } diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py index 0fc4f416c0..4834a029d1 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -4,9 +4,10 @@ Two runtime carriers coexist under ``runtime/``, both injected by the repo's ``scripts/build-exe-for-python-sdk.ts`` build (neither is checked into git): - **exe (production)**: single-file Node executables named - ``deepseek-harness-sdk-runtime--`` (platform in {linux, macos}, arch in - {x64, arm64}) with a sibling ``-rg`` executable; macOS also uses a sibling - ``-spawn-helper``. The target machine needs no Node installation. + ``deepseek-harness-sdk-runtime--`` for Linux/macOS and an + ``.exe`` counterpart for Windows. Each has a sibling ripgrep executable; + macOS also uses a sibling ``-spawn-helper``. The target machine needs no + Node installation. - **node (dev-only)**: the full deploy closure under ``runtime/node/`` (``package.json`` + ``node_modules/``), executed as ``node runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js`` on a @@ -30,7 +31,7 @@ PACKAGE_METADATA_FILENAME = "deepseek-harness-runtime.json" RUNTIME_MODE_ENV_VAR = "DSH_RUNTIME_MODE" -_PLATFORM_TAGS = {"linux": "linux", "darwin": "macos"} +_PLATFORM_TAGS = {"linux": "linux", "darwin": "macos", "win32": "win"} _ARCH_TAGS = {"x86_64": "x64", "amd64": "x64", "arm64": "arm64", "aarch64": "arm64"} _EXE_ACQUISITION_HINT = ( @@ -62,13 +63,18 @@ def bundled_runtime_path() -> Path: touching callers). """ tag = _current_platform_tag() - path = bundled_package_dir() / "runtime" / f"deepseek-harness-sdk-runtime-{tag}" + extension = ".exe" if tag.startswith("win-") else "" + path = bundled_package_dir() / "runtime" / f"deepseek-harness-sdk-runtime-{tag}{extension}" if not path.is_file(): raise FileNotFoundError( f"deepseek-harness-runtime-bin is missing the runtime executable at {path}. " + _EXE_ACQUISITION_HINT ) - ripgrep = Path(f"{path}-rg") + ripgrep = ( + path.with_name(f"{path.stem}-rg.exe") + if tag.startswith("win-") + else Path(f"{path}-rg") + ) if not ripgrep.is_file(): raise FileNotFoundError( f"deepseek-harness-runtime-bin is missing the ripgrep sidecar at {ripgrep}. " @@ -110,11 +116,11 @@ def resolve_bundled_launch_args(mode: str | None = None) -> tuple[str, ...]: def _current_platform_tag() -> str: plat = _PLATFORM_TAGS.get(sys.platform) arch = _ARCH_TAGS.get(platform.machine().lower()) - if plat is None or arch is None: + if plat is None or arch is None or (plat == "win" and arch != "x64"): raise FileNotFoundError( "no bundled DeepSeek Harness SDK runtime exists for this platform " f"(sys.platform={sys.platform!r}, machine={platform.machine()!r}); supported: " - "linux/macos on x64/arm64. " + _EXE_ACQUISITION_HINT + "Linux x64/arm64, macOS arm64, and Windows x64. " + _EXE_ACQUISITION_HINT ) return f"{plat}-{arch}" diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py index 5978d849ab..f6752a9906 100644 --- a/python/sdk/src/deepseek_harness/client.py +++ b/python/sdk/src/deepseek_harness/client.py @@ -93,11 +93,14 @@ class HarnessClient: self._start_stderr_thread() def close(self) -> None: + """Close the runtime after a bounded opportunity to flush durable state.""" proc = self._proc if proc is None: return + shutdown_completed = False try: self.request("shutdown", None, response_model=_ShutdownResponse, timeout_seconds=self.config.shutdown_timeout_seconds) + shutdown_completed = True except Exception as exc: self._stderr_lines.append(f"shutdown request failed: {exc}") if proc.stdin: @@ -105,16 +108,22 @@ class HarnessClient: proc.stdin.close() except Exception as exc: self._stderr_lines.append(f"stdin close failed: {exc}") + if shutdown_completed: + try: + proc.wait(timeout=self.config.shutdown_timeout_seconds) + except subprocess.TimeoutExpired: + pass if proc.poll() is None: try: proc.terminate() except ProcessLookupError: pass - try: - proc.wait(timeout=self.config.shutdown_timeout_seconds) - except subprocess.TimeoutExpired: - proc.kill() - proc.wait() + if proc.poll() is None: + try: + proc.wait(timeout=self.config.shutdown_timeout_seconds) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() self._proc = None self._fail_waiters(self._runtime_closed_error("DeepSeek Harness runtime closed")) if self._reader_thread and self._reader_thread.is_alive(): diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index 1ad89ffd4b..fba315320b 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -783,6 +783,43 @@ for line in sys.stdin: assert client._proc is None +def test_client_close_allows_eof_quiescence_after_shutdown_response(tmp_path: Path) -> None: + script = tmp_path / "fake_runtime.py" + marker = tmp_path / "quiesced.txt" + script.write_text( + """ +import json +import os +from pathlib import Path +import sys +import time + +for line in sys.stdin: + msg = json.loads(line) + if msg.get("method") == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-dsh"}}}), flush=True) + elif msg.get("method") == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + +time.sleep(0.05) +Path(os.environ["QUIESCED_MARKER"]).write_text("quiesced") +""".strip() + ) + + client = HarnessClient( + HarnessConfig( + _launch_args=(sys.executable, str(script)), + env={"QUIESCED_MARKER": str(marker)}, + shutdown_timeout_seconds=1, + ) + ) + client.start() + client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent") + client.close() + + assert marker.read_text() == "quiesced" + + def test_initialize_failure_reaps_started_runtime(tmp_path: Path) -> None: script = tmp_path / "rejecting_runtime.py" script.write_text( diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index deaa65b8a8..829c3b73f8 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -62,6 +62,14 @@ def test_macos_wheel_tag_does_not_claim_unsupported_node_platforms() -> None: assert build_python_release.PLATFORMS["macos-arm64"][1] == "deepseek-harness-sdk-runtime-macos-arm64" +def test_windows_wheel_tag_and_payload_are_x64_only() -> None: + assert build_python_release.PLATFORMS["win-x64"] == ( + "win_amd64", + "deepseek-harness-sdk-runtime-win-x64.exe", + ) + assert not any(name.startswith("win-") and name != "win-x64" for name in build_python_release.PLATFORMS) + + def test_platform_manifest_rejects_incomplete_entries(tmp_path: Path) -> None: manifest = tmp_path / "platforms.json" manifest.write_text('{"macos-arm64":{"tag":"macosx_14_0_arm64"}}\n') @@ -85,7 +93,10 @@ def test_stage_sdk_keeps_distribution_module_and_runtime_pin_distinct(tmp_path: assert (destination / "src" / "deepseek_harness" / "__init__.py").is_file() -@pytest.mark.parametrize(("target", "with_helper"), [("linux-x64", False), ("macos-arm64", True)]) +@pytest.mark.parametrize( + ("target", "with_helper"), + [("linux-x64", False), ("macos-arm64", True), ("win-x64.exe", False)], +) def test_stage_runtime_copies_platform_payload( tmp_path: Path, target: str, with_helper: bool ) -> None: @@ -93,7 +104,11 @@ def test_stage_runtime_copies_platform_payload( executable.write_bytes(b"runtime") executable.chmod(0o755) expected = {executable.name: b"runtime"} - ripgrep = Path(f"{executable}-rg") + ripgrep = ( + executable.with_name(f"{executable.stem}-rg.exe") + if executable.suffix == ".exe" + else Path(f"{executable}-rg") + ) ripgrep.write_bytes(b"ripgrep") ripgrep.chmod(0o755) expected[ripgrep.name] = b"ripgrep" diff --git a/python/sdk/tests/test_runtime_resolution.py b/python/sdk/tests/test_runtime_resolution.py index fc54171b75..beaf5cfd6b 100644 --- a/python/sdk/tests/test_runtime_resolution.py +++ b/python/sdk/tests/test_runtime_resolution.py @@ -55,6 +55,30 @@ def test_runtime_requires_spawn_helper_only_on_macos( assert runtime.bundled_runtime_path() == linux +def test_windows_runtime_uses_exe_payload_and_exe_sidecar( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + runtime_dir = tmp_path / "runtime" + runtime_dir.mkdir() + executable = runtime_dir / "deepseek-harness-sdk-runtime-win-x64.exe" + executable.touch() + (runtime_dir / "deepseek-harness-sdk-runtime-win-x64-rg.exe").touch() + monkeypatch.setattr(runtime, "bundled_package_dir", lambda: tmp_path) + monkeypatch.setattr(runtime, "_current_platform_tag", lambda: "win-x64") + + assert runtime.bundled_runtime_path() == executable + + +def test_current_platform_supports_windows_x64_only(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(runtime.sys, "platform", "win32") + monkeypatch.setattr(runtime.platform, "machine", lambda: "AMD64") + assert runtime._current_platform_tag() == "win-x64" + + monkeypatch.setattr(runtime.platform, "machine", lambda: "ARM64") + with pytest.raises(FileNotFoundError, match="Windows x64"): + runtime._current_platform_tag() + + def test_runtime_requires_ripgrep_sidecar( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/scripts/build-exe-for-python-sdk-native-pty.spec.ts b/scripts/build-exe-for-python-sdk-native-pty.spec.ts index 5dd6588955..cc7d0ef7fa 100644 --- a/scripts/build-exe-for-python-sdk-native-pty.spec.ts +++ b/scripts/build-exe-for-python-sdk-native-pty.spec.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { resolveLinuxNodePtyAddon } from './build-exe-for-python-sdk-native-pty.ts' +import { resolveLinuxNodePtyAddon, resolveWindowsNodePtyAddons } from './build-exe-for-python-sdk-native-pty.ts' const roots: string[] = [] @@ -35,6 +35,24 @@ describe('resolveLinuxNodePtyAddon', () => { }) }) +describe('resolveWindowsNodePtyAddons', () => { + it('requires both ConPTY addons from the x64 prebuild', () => { + const root = temporaryPackage() + const conpty = createAddon(root, 'prebuilds', 'win32-x64', 'conpty.node') + const consoleList = createAddon(root, 'prebuilds', 'win32-x64', 'conpty_console_list.node') + + expect(resolveWindowsNodePtyAddons(root, 'x64')).toEqual([conpty, consoleList]) + }) + + it('names every missing Windows addon', () => { + const root = temporaryPackage() + + expect(() => resolveWindowsNodePtyAddons(root, 'x64')).toThrow( + `Windows node-pty addons are missing: ${join(root, 'prebuilds', 'win32-x64', 'conpty.node')}, ${join(root, 'prebuilds', 'win32-x64', 'conpty_console_list.node')}`, + ) + }) +}) + function temporaryPackage(): string { const root = mkdtempSync(join(tmpdir(), 'dsh-node-pty-addon-')) roots.push(root) diff --git a/scripts/build-exe-for-python-sdk-native-pty.ts b/scripts/build-exe-for-python-sdk-native-pty.ts index 02fa864d73..3ce5295d5c 100644 --- a/scripts/build-exe-for-python-sdk-native-pty.ts +++ b/scripts/build-exe-for-python-sdk-native-pty.ts @@ -21,3 +21,25 @@ export function resolveLinuxNodePtyAddon( `build-exe-for-python-sdk: node-pty addon is absent from both ${built} and ${prebuilt}.`, ) } + +/** + * Require both node-pty addons used by the Windows ConPTY backend. + * @param packageDirectory - staged node-pty package directory. + * @param arch - Windows target architecture. + * @returns the existing addon paths in load order. + */ +export function resolveWindowsNodePtyAddons( + packageDirectory: string, + arch: 'x64', +): string[] { + const directory = join(packageDirectory, 'prebuilds', `win32-${arch}`) + const addons = [ + join(directory, 'conpty.node'), + join(directory, 'conpty_console_list.node'), + ] + const missing = addons.filter(path => !existsSync(path)) + if (missing.length > 0) { + throw new Error(`build-exe-for-python-sdk: Windows node-pty addons are missing: ${missing.join(', ')}.`) + } + return addons +} diff --git a/scripts/build-exe-for-python-sdk.spec.ts b/scripts/build-exe-for-python-sdk.spec.ts new file mode 100644 index 0000000000..c3fe4a15a6 --- /dev/null +++ b/scripts/build-exe-for-python-sdk.spec.ts @@ -0,0 +1,81 @@ +import { spawnSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' + +const root = resolve(import.meta.dirname, '..') +const script = resolve(root, 'scripts/build-exe-for-python-sdk.ts') +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function run(env: NodeJS.ProcessEnv, ...args: string[]) { + return spawnSync(process.execPath, ['--import', 'tsx/esm', script, ...args], { + cwd: root, + encoding: 'utf8', + env: isolatedPnpmEnvironment(env), + }) +} + +describe('Python runtime executable builder CLI', () => { + it('runs pnpm through its JavaScript entrypoint without a command shell', () => { + const result = run( + { npm_execpath: 'C:\\tools\\pnpm.cjs' }, + '--skip-build', + '--dry-run', + '--targets=node24-macos-arm64', + ) + + expect(result.status).toBe(0) + expect(result.stdout).toContain(`${process.execPath} C:\\tools\\pnpm.cjs run verify-runtime-closure`) + expect(result.stdout).toContain(`${process.execPath} C:\\tools\\pnpm.cjs --filter dsh-python-runtime-closure deploy`) + expect(result.stdout).toContain(`${process.execPath} C:\\tools\\pnpm.cjs dlx @yao-pkg/pkg@6.21.0`) + expect(result.stdout).not.toMatch(/pnpm\.cmd/i) + }) + + it('resolves the pnpm package behind a Windows command shim', () => { + const setup = mkdtempSync(join(tmpdir(), 'dsh-pnpm-home-')) + temporaryDirectories.push(setup) + const home = join(setup, 'node_modules', '.bin') + const entrypoint = join(setup, 'node_modules', 'pnpm', 'bin', 'pnpm.mjs') + mkdirSync(home, { recursive: true }) + mkdirSync(dirname(entrypoint), { recursive: true }) + writeFileSync(entrypoint, '') + + const result = run( + { npm_execpath: 'C:\\tools\\pnpm.cmd', PNPM_HOME: home }, + '--skip-build', + '--dry-run', + '--targets=node24-macos-arm64', + ) + + expect(result.status).toBe(0) + expect(result.stdout).toContain(`${process.execPath} ${entrypoint} run verify-runtime-closure`) + expect(result.stdout).not.toMatch(/pnpm\.cmd/i) + }) + + it('rejects a Windows arm64 product before any build step', () => { + const result = run( + { npm_execpath: 'C:\\tools\\pnpm.cjs' }, + '--skip-build', + '--dry-run', + '--targets=node24-win-arm64', + ) + + expect(result.status).not.toBe(0) + expect(result.stderr).toContain('Windows supports x64 only') + expect(result.stdout).toBe('') + }) +}) + +function isolatedPnpmEnvironment(overrides: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const environment = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !['npm_execpath', 'pnpm_home'].includes(key.toLowerCase())), + ) + return { ...environment, ...overrides } +} diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index c8c30b4301..c7c8cfed66 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -9,9 +9,9 @@ import { spawn } from 'node:child_process' import { existsSync, statSync } from 'node:fs' import { chmod, copyFile, cp, lstat, mkdir, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises' -import { basename, dirname, join, resolve, sep } from 'node:path' +import { basename, dirname, extname, join, resolve, sep } from 'node:path' import { parseArgs } from 'node:util' -import { resolveLinuxNodePtyAddon } from './build-exe-for-python-sdk-native-pty.ts' +import { resolveLinuxNodePtyAddon, resolveWindowsNodePtyAddons } from './build-exe-for-python-sdk-native-pty.ts' const root = resolve(import.meta.dirname, '..') @@ -63,7 +63,7 @@ const ASSET_GLOBS = [ 'node_modules/@deepseek-ai/dsh-skill-badge/assets/**/*', ] -const PLATFORMS = ['linux', 'macos'] as const +const PLATFORMS = ['linux', 'macos', 'win'] as const const ARCHES = ['x64', 'arm64'] as const type Platform = (typeof PLATFORMS)[number] type Arch = (typeof ARCHES)[number] @@ -83,10 +83,7 @@ class Target { private constructor( /** pkg Node range (`node`). */ readonly nodeRange: string, - /** - * pkg platform tag. Windows is a documented non-goal - * (.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). - */ + /** pkg platform tag. */ readonly platform: Platform, /** pkg CPU tag. */ readonly arch: Arch, @@ -117,6 +114,9 @@ class Target { if (!isArch(arch)) { throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: arch must be one of ${ARCHES.join(', ')}, got ${JSON.stringify(arch)}.`) } + if (platform === 'win' && arch !== 'x64') { + throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: Windows supports x64 only.`) + } return new Target(nodeRange, platform, arch) } @@ -125,7 +125,13 @@ class Target { * @returns the host target; throws on an unsupported host platform or arch. */ static host(): Target { - const platform = process.platform === 'darwin' ? 'macos' : process.platform === 'linux' ? 'linux' : undefined + const platform = process.platform === 'darwin' + ? 'macos' + : process.platform === 'linux' + ? 'linux' + : process.platform === 'win32' + ? 'win' + : undefined if (platform === undefined) { throw new Error(`build-exe-for-python-sdk: unsupported host platform ${process.platform}; pass --targets explicitly.`) } @@ -133,6 +139,9 @@ class Target { if (arch === undefined) { throw new Error(`build-exe-for-python-sdk: unsupported host arch ${process.arch}; pass --targets explicitly.`) } + if (platform === 'win' && arch !== 'x64') { + throw new Error('build-exe-for-python-sdk: Windows supports x64 only; use an x64 Node process.') + } return new Target(DEFAULT_NODE_RANGE, platform, arch) } } @@ -200,7 +209,7 @@ class BuildCli { return [ 'Usage: pnpm exec tsx scripts/build-exe-for-python-sdk.ts [flags]', '', - ' --targets= pkg targets, e.g. node24-linux-x64,node24-linux-arm64,node24-macos-arm64.', + ' --targets= pkg targets, e.g. node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-win-x64.', ' Default: the host platform only (on node24).', ' --skip-build skip `pnpm run build` (lib/ artifacts must already exist).', ' --dry-run print every command and config patch without executing.', @@ -212,8 +221,27 @@ class BuildCli { } } -function pnpmBin(): string { - return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' +function pnpmInvocation(args: string[]): [command: string, args: string[]] { + const entrypoint = process.env.npm_execpath?.trim() + if (entrypoint !== undefined && entrypoint !== '') { + const extension = extname(entrypoint).toLowerCase() + if (extension === '.js' || extension === '.cjs' || extension === '.mjs') { + return [process.execPath, [entrypoint, ...args]] + } + if (extension !== '.cmd') return [entrypoint, args] + } + const home = process.env.PNPM_HOME?.trim() + if (home !== undefined && home !== '') { + const packageBin = resolve(home, '..', 'pnpm', 'bin') + for (const filename of ['pnpm.mjs', 'pnpm.cjs']) { + const candidate = resolve(packageBin, filename) + if (existsSync(candidate)) return [process.execPath, [candidate, ...args]] + } + } + if (process.platform === 'win32') { + throw new Error('build-exe-for-python-sdk: pnpm must expose a JavaScript entrypoint through npm_execpath or PNPM_HOME on Windows.') + } + return ['pnpm', args] } /** @@ -241,7 +269,7 @@ class SingleExeBuild { /** Verify the closure before compiling or packaging. */ async verifyClosure(): Promise { - await this.run('runtime dependency closure', pnpmBin(), ['run', 'verify-runtime-closure']) + await this.runPnpm('runtime dependency closure', ['run', 'verify-runtime-closure']) } /** Build all package artifacts unless `--skip-build` was passed. */ @@ -250,7 +278,7 @@ class SingleExeBuild { console.log('build-exe-for-python-sdk: skipping pnpm run build (--skip-build)') return } - await this.run('build', pnpmBin(), ['run', 'build']) + await this.runPnpm('build', ['run', 'build']) } /** Clear and deploy the runtime closure into the node carrier. */ @@ -260,7 +288,7 @@ class SingleExeBuild { } if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] rm -rf ${this.staging}`) else await rm(this.staging, { recursive: true, force: true }) - await this.run('deploy', pnpmBin(), [ + await this.runPnpm('deploy', [ '--filter', DEPLOY_ROOT_PACKAGE, 'deploy', @@ -393,10 +421,11 @@ class SingleExeBuild { * @returns the executable and ripgrep sidecar paths, plus the macOS spawn helper path when required. */ async pack(target: Target): Promise { - const product = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`) + const productBase = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`) + const product = target.platform === 'win' ? `${productBase}.exe` : productBase await this.prepareNativePty(target) if (!this.cli.dryRun) await mkdir(this.outDir, { recursive: true }) - await this.run(`pkg ${target.spec}`, pnpmBin(), [ + await this.runPnpm(`pkg ${target.spec}`, [ 'dlx', PKG_SPEC, this.staging, @@ -424,16 +453,19 @@ class SingleExeBuild { /** Copy the target ripgrep binary beside the executable so Node can spawn it outside pkg's virtual filesystem. */ private async copyRipgrepSidecar(target: Target, product: string): Promise { - const platform = target.platform === 'macos' ? 'darwin' : target.platform + const platform = target.platform === 'macos' ? 'darwin' : target.platform === 'win' ? 'win32' : target.platform + const executable = target.platform === 'win' ? 'rg.exe' : 'rg' const source = join( this.staging, 'node_modules', '@vscode', `ripgrep-${platform}-${target.arch}`, 'bin', - 'rg', + executable, ) - const destination = `${product}-rg` + const destination = target.platform === 'win' + ? `${product.slice(0, -'.exe'.length)}-rg.exe` + : `${product}-rg` if (this.cli.dryRun) { console.log(`build-exe-for-python-sdk: [dry-run] cp ${source} ${destination}`) return destination @@ -455,7 +487,6 @@ class SingleExeBuild { const stagedBuild = join(this.staging, 'node_modules', 'node-pty', 'build') if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] rm -rf ${stagedBuild}`) else await rm(stagedBuild, { recursive: true, force: true }) - if (target.platform !== 'linux') return const packageDirectory = join( root, 'packages', @@ -464,6 +495,21 @@ class SingleExeBuild { 'node_modules', 'node-pty', ) + if (target.platform === 'win') { + if (target.arch !== 'x64') { + throw new Error('build-exe-for-python-sdk: Windows supports x64 only.') + } + const host = Target.host() + if (target.platform !== host.platform || target.arch !== host.arch) { + throw new Error( + 'build-exe-for-python-sdk: build the Windows runtime under x64 Node on its target host; ' + + `target ${target.platform}-${target.arch} does not match host ${host.platform}-${host.arch}.`, + ) + } + resolveWindowsNodePtyAddons(join(this.staging, 'node_modules', 'node-pty'), target.arch) + return + } + if (target.platform !== 'linux') return const destination = join(stagedBuild, 'Release', 'pty.node') const source = resolveLinuxNodePtyAddon(packageDirectory, target.arch) if (this.cli.dryRun) { @@ -553,6 +599,12 @@ class SingleExeBuild { }) }) } + + /** Run pnpm through its JavaScript entrypoint when the caller supplies one. */ + private async runPnpm(label: string, args: string[]): Promise { + const [command, invocationArgs] = pnpmInvocation(args) + await this.run(label, command, invocationArgs) + } } async function main(): Promise { diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index c546c6bb42..307fe759dd 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -47,9 +47,12 @@ def load_platforms(path: Path = PLATFORM_MANIFEST) -> dict[str, tuple[str, str]] PLATFORMS = load_platforms() -def runtime_suffixes(executable_name: str) -> tuple[str, ...]: - suffixes = ("", "-rg") - return (*suffixes, "-spawn-helper") if "-macos-" in executable_name else suffixes +def runtime_filenames(executable_name: str) -> tuple[str, ...]: + """Return the exact platform payload names for one runtime executable.""" + if executable_name.endswith(".exe"): + return (executable_name, f"{executable_name.removesuffix('.exe')}-rg.exe") + names = (executable_name, f"{executable_name}-rg") + return (*names, f"{executable_name}-spawn-helper") if "-macos-" in executable_name else names def main() -> None: @@ -210,8 +213,9 @@ def stage_runtime(destination: Path, version: str, executable: Path, executable_ rewrite_version(destination / "pyproject.toml", version) runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" runtime_dir.mkdir(parents=True, exist_ok=True) - for suffix in runtime_suffixes(executable_name): - shutil.copy2(Path(f"{executable}{suffix}"), runtime_dir / f"{executable_name}{suffix}") + source_directory = executable.parent + for filename in runtime_filenames(executable_name): + shutil.copy2(source_directory / filename, runtime_dir / filename) def verify_wheel( @@ -250,13 +254,13 @@ def verify_wheel( ] if package == "runtime": assert platform is not None - expected_files = [f"{platform[1]}{suffix}" for suffix in runtime_suffixes(platform[1])] + expected_files = sorted(runtime_filenames(platform[1])) found_files = sorted(Path(name).name for name in runtime_files) if found_files != expected_files: raise RuntimeError(f"{wheel} runtime payload must be {expected_files}, found {found_files}") for runtime_file in runtime_files: mode = archive.getinfo(runtime_file).external_attr >> 16 - if mode & stat.S_IXUSR == 0: + if platform[0] != "win_amd64" and mode & stat.S_IXUSR == 0: raise RuntimeError(f"{wheel} runtime executable lost its executable bit: {runtime_file}") elif runtime_files: raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {runtime_files}") diff --git a/scripts/verify-runtime-closure.spec.ts b/scripts/verify-runtime-closure.spec.ts index 6a395afe30..90dbf20799 100644 --- a/scripts/verify-runtime-closure.spec.ts +++ b/scripts/verify-runtime-closure.spec.ts @@ -21,6 +21,7 @@ const platforms = { 'linux-x64': { tag: 'manylinux_2_28_x86_64', executable: 'runtime-linux-x64' }, 'linux-arm64': { tag: 'manylinux_2_28_aarch64', executable: 'runtime-linux-arm64' }, 'macos-arm64': { tag: 'macosx_14_0_arm64', executable: 'runtime-macos-arm64' }, + 'win-x64': { tag: 'win_amd64', executable: 'runtime-win-x64.exe' }, } function workspace(root: string, name: string, manifest: Record): void { @@ -35,7 +36,7 @@ afterEach(() => { }) describe('verifyRuntimeClosure', () => { - it('requires only plugins active for a Linux or macOS target', async () => { + it('requires only plugins active for each published target', async () => { const root = fixture({ 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: { '@scope/shared': 'workspace:^' } }, 'python/sdk-runtime/platforms.json': platforms, @@ -52,6 +53,9 @@ describe('verifyRuntimeClosure', () => { - id: macos name: '@scope/macos' disabled: !!js process.platform !== 'darwin' + - id: windows + name: '@scope/windows' + disabled: !!js process.platform !== 'win32' `, }) @@ -61,6 +65,7 @@ describe('verifyRuntimeClosure', () => { expect(result.failures).toEqual([ 'standard preset -> @scope/linux (linux-arm64, linux-x64)', 'standard preset -> @scope/macos (macos-arm64)', + 'standard preset -> @scope/windows (win-x64)', ]) }) @@ -78,7 +83,7 @@ describe('verifyRuntimeClosure', () => { const result = await verifyRuntimeClosure(root) expect(result.failures).toEqual([ - 'standard preset -> @scope/conditional (linux-arm64, linux-x64, macos-arm64)', + 'standard preset -> @scope/conditional (linux-arm64, linux-x64, macos-arm64, win-x64)', ]) }) @@ -112,7 +117,7 @@ describe('verifyRuntimeClosure', () => { const result = await verifyRuntimeClosure(root) expect(result.failures).toEqual([ - 'standard preset -> @scope/plugin [runtime dependency is "1.2.3"; expected workspace:] (linux-arm64, linux-x64, macos-arm64)', + 'standard preset -> @scope/plugin [runtime dependency is "1.2.3"; expected workspace:] (linux-arm64, linux-x64, macos-arm64, win-x64)', ]) }) diff --git a/scripts/verify-runtime-closure.ts b/scripts/verify-runtime-closure.ts index d0127fc68d..b0dac1454b 100644 --- a/scripts/verify-runtime-closure.ts +++ b/scripts/verify-runtime-closure.ts @@ -181,6 +181,7 @@ function disabledOnPlatform(value: unknown, processPlatform: string): boolean { function processPlatformForTarget(target: string): string { if (target.startsWith('linux-')) return 'linux' if (target.startsWith('macos-')) return 'darwin' + if (target.startsWith('win-')) return 'win32' throw new Error(`verify-runtime-closure: unsupported runtime target ${JSON.stringify(target)}`) }