diff --git a/packages/runpane-py/src/runpane/version.py b/packages/runpane-py/src/runpane/version.py index f98daee4..989370d7 100644 --- a/packages/runpane-py/src/runpane/version.py +++ b/packages/runpane-py/src/runpane/version.py @@ -1,11 +1,14 @@ from __future__ import annotations +import os import subprocess +import sys from importlib import metadata from typing import Optional from . import __version__ PANE_VERSION_TIMEOUT_SECONDS = 2 +POWERSHELL_TIMEOUT_SECONDS = 2 def wrapper_version() -> str: @@ -21,6 +24,9 @@ def print_version(pane_path: object = None) -> int: def pane_version(executable_path: str) -> Optional[str]: + if sys.platform == "win32": + return _windows_file_version(executable_path) + try: result = subprocess.run( [executable_path, "--version"], @@ -32,3 +38,38 @@ def pane_version(executable_path: str) -> Optional[str]: return None output = (result.stdout + result.stderr).strip() return output or None + + +def _windows_file_version(executable_path: str) -> Optional[str]: + script = "; ".join( + [ + "$ErrorActionPreference = 'Stop'", + "$target = $env:RUNPANE_PANE_VERSION_PATH", + "if (-not $target) { exit 1 }", + "$info = (Get-Item -LiteralPath $target).VersionInfo", + "if ($info.FileVersion) { $info.FileVersion } elseif ($info.ProductVersion) { $info.ProductVersion }", + ] + ) + try: + env = { + **os.environ, + "RUNPANE_PANE_VERSION_PATH": executable_path, + } + result = subprocess.run( + [ + "powershell.exe", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + script, + ], + capture_output=True, + env=env, + text=True, + timeout=POWERSHELL_TIMEOUT_SECONDS, + ) + except (OSError, subprocess.TimeoutExpired): + return None + return result.stdout.strip() or None diff --git a/packages/runpane/src/version.ts b/packages/runpane/src/version.ts index 599039d6..ca7f37cd 100644 --- a/packages/runpane/src/version.ts +++ b/packages/runpane/src/version.ts @@ -3,6 +3,7 @@ import fs from 'fs'; import path from 'path'; const PANE_VERSION_TIMEOUT_MS = 2_000; +const POWERSHELL_TIMEOUT_MS = 2_000; export function getWrapperVersion(): string { const packagePath = path.resolve(__dirname, '..', 'package.json'); @@ -21,6 +22,10 @@ export async function printVersion(_panePath?: string): Promise { } export function getPaneVersion(executablePath: string): string | undefined { + if (process.platform === 'win32') { + return getWindowsFileVersion(executablePath); + } + try { const result = childProcess.spawnSync(executablePath, ['--version'], { encoding: 'utf8', @@ -37,3 +42,39 @@ export function getPaneVersion(executablePath: string): string | undefined { return undefined; } } + +function getWindowsFileVersion(executablePath: string): string | undefined { + const script = [ + "$ErrorActionPreference = 'Stop'", + '$target = $env:RUNPANE_PANE_VERSION_PATH', + 'if (-not $target) { exit 1 }', + '$info = (Get-Item -LiteralPath $target).VersionInfo', + 'if ($info.FileVersion) { $info.FileVersion } elseif ($info.ProductVersion) { $info.ProductVersion }' + ].join('; '); + + try { + const result = childProcess.spawnSync('powershell.exe', [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-Command', + script + ], { + encoding: 'utf8', + env: { + ...process.env, + RUNPANE_PANE_VERSION_PATH: executablePath + }, + stdio: ['ignore', 'pipe', 'pipe'], + timeout: POWERSHELL_TIMEOUT_MS, + windowsHide: true + }); + if (result.error) { + return undefined; + } + return result.stdout.trim() || undefined; + } catch { + return undefined; + } +} diff --git a/scripts/test-runpane-contract.js b/scripts/test-runpane-contract.js index 888e957f..a93a60f6 100644 --- a/scripts/test-runpane-contract.js +++ b/scripts/test-runpane-contract.js @@ -688,6 +688,80 @@ finally: }); } +function checkWindowsPaneVersionDoesNotLaunchExecutable() { + if (process.platform === 'win32') { + const versionModule = require(path.join(rootDir, 'packages', 'runpane', 'dist', 'version.js')); + const originalSpawnSync = childProcess.spawnSync; + const paneExe = 'C:\\Program Files\\Pane\\Pane.exe'; + const calls = []; + + try { + childProcess.spawnSync = (command, args, options) => { + calls.push({ command, args, options }); + assert.notStrictEqual(command, paneExe); + assert.strictEqual(command, 'powershell.exe'); + assert.strictEqual(options.env.RUNPANE_PANE_VERSION_PATH, paneExe); + return { stdout: '2.3.19\r\n', stderr: '', status: 0 }; + }; + + assert.strictEqual(versionModule.getPaneVersion(paneExe), '2.3.19'); + assert.strictEqual(calls.length, 1); + + childProcess.spawnSync = (command) => { + assert.notStrictEqual(command, paneExe); + return { error: new Error('metadata unavailable'), stdout: '', stderr: '' }; + }; + assert.strictEqual(versionModule.getPaneVersion(paneExe), undefined); + } finally { + childProcess.spawnSync = originalSpawnSync; + } + } + + const pythonOutput = runPythonSnippet(` +import json +import runpane.version as version + +original_platform = version.sys.platform +original_run = version.subprocess.run +pane_exe = r"C:\\Program Files\\Pane\\Pane.exe" +calls = [] + +class Result: + def __init__(self, stdout): + self.stdout = stdout + self.stderr = "" + +def fake_run(args, **kwargs): + calls.append(args) + assert args[0] == "powershell.exe" + assert kwargs["env"]["RUNPANE_PANE_VERSION_PATH"] == pane_exe + return Result("2.3.19\\n") + +try: + version.sys.platform = "win32" + version.subprocess.run = fake_run + first = version.pane_version(pane_exe) + + def missing_metadata(args, **kwargs): + assert args[0] == "powershell.exe" + return Result("") + + version.subprocess.run = missing_metadata + second = version.pane_version(pane_exe) +finally: + version.sys.platform = original_platform + version.subprocess.run = original_run + +print(json.dumps({"first": first, "second": second, "calls": len(calls)})) +`); + + assert.deepStrictEqual(JSON.parse(pythonOutput), { + first: '2.3.19', + second: null, + calls: 1 + }); +} + async function checkFromJsonAcceptsBom() { const payloadPath = path.join(os.tmpdir(), `runpane-from-json-bom-${process.pid}.json`); const payload = { @@ -920,6 +994,7 @@ async function runChecks() { compareExistingReusePolicy(); checkPlatformMatchingEdgeCases(); await checkExistingDaemonShortCircuit(); + checkWindowsPaneVersionDoesNotLaunchExecutable(); await checkFromJsonAcceptsBom(); checkHelpOutput(); compareAgentContextParity();