aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-07-25 02:40:14 +0300
committerGitHub <noreply@github.com>2026-07-25 02:40:14 +0300
commit8afd52ae57f9237f4f46138ad224dfc621d7ba59 (patch)
tree349bbbd86c57863539ebc4c2eacf34550e076fa2 /scripts
parent5f1957c27bd23f73b339265d3acd32f4a21878a3 (diff)
fix: bundle modern ConPTY on Windows (#1828)
refs #1533 refs #1644
Diffstat (limited to 'scripts')
-rw-r--r--scripts/package_windows_conpty.ps153
-rw-r--r--scripts/package_windows_conpty.py245
-rw-r--r--scripts/preview.py6
-rw-r--r--scripts/test_package_windows_conpty.py171
-rw-r--r--scripts/test_preview.py29
-rw-r--r--scripts/test_vendor_portable_pty.py13
-rw-r--r--scripts/windows_conpty_enhanced_input_probe.ps1418
-rw-r--r--scripts/windows_install_conpty_package_test.ps1146
-rw-r--r--scripts/windows_smoke_conpty_path.ps136
9 files changed, 1108 insertions, 9 deletions
diff --git a/scripts/package_windows_conpty.ps1 b/scripts/package_windows_conpty.ps1
new file mode 100644
index 00000000..a94b609e
--- /dev/null
+++ b/scripts/package_windows_conpty.ps1
@@ -0,0 +1,53 @@
+param(
+ [Parameter(Mandatory = $true)]
+ [string]$HerdrExe,
+
+ [Parameter(Mandatory = $true)]
+ [string]$PackagePath,
+
+ [Parameter(Mandatory = $true)]
+ [string]$StageDir,
+
+ [Parameter(Mandatory = $true)]
+ [string]$OutputPath
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = "Stop"
+
+function Invoke-NativeChecked {
+ param(
+ [string]$Command,
+ [string[]]$Arguments
+ )
+
+ & $Command @Arguments
+ if ($LASTEXITCODE -ne 0) {
+ throw "$Command failed with exit code $LASTEXITCODE"
+ }
+}
+
+$packager = Join-Path $PSScriptRoot "package_windows_conpty.py"
+Invoke-NativeChecked python @(
+ $packager,
+ "stage",
+ "--package", $PackagePath,
+ "--herdr-exe", $HerdrExe,
+ "--output-dir", $StageDir
+)
+Invoke-NativeChecked dotnet @("nuget", "verify", "--all", $PackagePath)
+
+foreach ($relative in @("conpty\conpty.dll", "conpty\x64\OpenConsole.exe", "conpty\arm64\OpenConsole.exe")) {
+ $signature = Get-AuthenticodeSignature (Join-Path $StageDir $relative)
+ $subject = if ($null -eq $signature.SignerCertificate) { "" } else { $signature.SignerCertificate.Subject }
+ if ($signature.Status -ne "Valid" -or $subject -notlike "*Microsoft Corporation*") {
+ throw "Invalid Microsoft signature for $relative`: $($signature.Status) $subject"
+ }
+}
+
+Invoke-NativeChecked python @(
+ $packager,
+ "archive",
+ "--stage-dir", $StageDir,
+ "--output", $OutputPath
+)
diff --git a/scripts/package_windows_conpty.py b/scripts/package_windows_conpty.py
new file mode 100644
index 00000000..2fc51a1e
--- /dev/null
+++ b/scripts/package_windows_conpty.py
@@ -0,0 +1,245 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import shutil
+import struct
+import tempfile
+import urllib.request
+import xml.etree.ElementTree as ET
+import zipfile
+from pathlib import Path, PurePosixPath
+from typing import Any
+
+PROJECT_ROOT = Path(__file__).resolve().parent.parent
+DEFAULT_METADATA = PROJECT_ROOT / "packaging" / "windows" / "conpty.json"
+MARKER_PATH = PurePosixPath("conpty/herdr-conpty.json")
+DOWNLOAD_TIMEOUT_SECONDS = 60
+
+
+def sha256_bytes(data: bytes) -> str:
+ return hashlib.sha256(data).hexdigest()
+
+
+def sha256_file(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as source:
+ for chunk in iter(lambda: source.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def load_metadata(path: Path) -> dict[str, Any]:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ if data.get("schema_version") != 1:
+ raise ValueError("unsupported ConPTY metadata schema")
+ return data
+
+
+def pe_machine(data: bytes) -> int:
+ if len(data) < 0x40 or data[:2] != b"MZ":
+ raise ValueError("file is not a PE image")
+ pe_offset = struct.unpack_from("<I", data, 0x3C)[0]
+ if pe_offset + 6 > len(data) or data[pe_offset : pe_offset + 4] != b"PE\0\0":
+ raise ValueError("file has an invalid PE header")
+ return struct.unpack_from("<H", data, pe_offset + 4)[0]
+
+
+def validate_nuspec(archive: zipfile.ZipFile, package: dict[str, Any]) -> None:
+ nuspec_name = f"{package['id']}.nuspec"
+ try:
+ root = ET.fromstring(archive.read(nuspec_name).decode("utf-8-sig"))
+ except KeyError as error:
+ raise ValueError(f"package is missing {nuspec_name}") from error
+ namespace = {"n": "http://schemas.microsoft.com/packaging/2011/08/nuspec.xsd"}
+ metadata = root.find("n:metadata", namespace)
+ if metadata is None:
+ raise ValueError("package NuSpec is missing metadata")
+ expected = {
+ "id": package["id"],
+ "version": package["version"],
+ "license": package["license"],
+ }
+ for field, expected_value in expected.items():
+ element = metadata.find(f"n:{field}", namespace)
+ actual = element.text.strip() if element is not None and element.text else None
+ if actual != expected_value:
+ raise ValueError(
+ f"package NuSpec {field} mismatch: expected {expected_value!r}, got {actual!r}"
+ )
+
+
+def acquire_package(package: dict[str, Any], package_path: Path) -> None:
+ package_path.parent.mkdir(parents=True, exist_ok=True)
+ if not package_path.exists():
+ with urllib.request.urlopen(
+ package["url"], timeout=DOWNLOAD_TIMEOUT_SECONDS
+ ) as response, package_path.open("wb") as output:
+ shutil.copyfileobj(response, output)
+ actual = sha256_file(package_path)
+ if actual != package["sha256"]:
+ raise ValueError(
+ f"ConPTY package hash mismatch: expected {package['sha256']}, got {actual}"
+ )
+
+
+def marker_data(metadata: dict[str, Any], architecture: str) -> bytes:
+ bundle = metadata["bundles"][architecture]
+ marker = {
+ "schema_version": 1,
+ "package": metadata["package"]["id"],
+ "version": metadata["package"]["version"],
+ "architecture": architecture,
+ "files": {
+ item["destination"]: item["sha256"] for item in bundle["files"]
+ },
+ }
+ return (json.dumps(marker, indent=2, sort_keys=True) + "\n").encode()
+
+
+def stage_bundle(
+ metadata_path: Path,
+ architecture: str,
+ package_path: Path,
+ herdr_exe: Path,
+ output_dir: Path,
+) -> None:
+ metadata = load_metadata(metadata_path)
+ if architecture not in metadata["bundles"]:
+ raise ValueError(f"unsupported Windows architecture: {architecture}")
+ if output_dir.exists():
+ raise ValueError(f"output directory already exists: {output_dir}")
+ if not herdr_exe.is_file():
+ raise ValueError(f"Herdr executable does not exist: {herdr_exe}")
+
+ acquire_package(metadata["package"], package_path)
+ bundle = metadata["bundles"][architecture]
+ metadata_root = metadata_path.resolve().parent
+
+ with zipfile.ZipFile(package_path) as archive, tempfile.TemporaryDirectory(
+ prefix="herdr-conpty-stage-", dir=output_dir.parent
+ ) as temporary:
+ validate_nuspec(archive, metadata["package"])
+ staging = Path(temporary) / "bundle"
+ staging.mkdir()
+ shutil.copy2(herdr_exe, staging / "herdr.exe")
+
+ for item in bundle["files"]:
+ try:
+ payload = archive.read(item["source"])
+ except KeyError as error:
+ raise ValueError(f"package is missing {item['source']}") from error
+ actual_hash = sha256_bytes(payload)
+ if actual_hash != item["sha256"]:
+ raise ValueError(
+ f"hash mismatch for {item['source']}: expected {item['sha256']}, got {actual_hash}"
+ )
+ actual_machine = pe_machine(payload)
+ expected_machine = int(item["pe_machine"], 16)
+ if actual_machine != expected_machine:
+ raise ValueError(
+ f"PE machine mismatch for {item['source']}: "
+ f"expected {item['pe_machine']}, got 0x{actual_machine:04x}"
+ )
+ destination = staging / PurePosixPath(item["destination"])
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ destination.write_bytes(payload)
+
+ marker = staging / MARKER_PATH
+ marker.parent.mkdir(parents=True, exist_ok=True)
+ marker.write_bytes(marker_data(metadata, architecture))
+
+ for notice in metadata["notices"]:
+ source = (metadata_root.parent.parent / notice["source"]).resolve()
+ actual_hash = sha256_file(source)
+ if actual_hash != notice["sha256"]:
+ raise ValueError(
+ f"notice hash mismatch for {source}: expected {notice['sha256']}, got {actual_hash}"
+ )
+ destination = staging / PurePosixPath(notice["destination"])
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(source, destination)
+
+ staging.rename(output_dir)
+
+
+def expected_stage_files(metadata: dict[str, Any], architecture: str) -> set[str]:
+ files = {"herdr.exe", MARKER_PATH.as_posix()}
+ files.update(item["destination"] for item in metadata["bundles"][architecture]["files"])
+ files.update(item["destination"] for item in metadata["notices"])
+ return files
+
+
+def validate_stage(metadata_path: Path, architecture: str, stage_dir: Path) -> None:
+ metadata = load_metadata(metadata_path)
+ actual = {
+ path.relative_to(stage_dir).as_posix()
+ for path in stage_dir.rglob("*")
+ if path.is_file()
+ }
+ expected = expected_stage_files(metadata, architecture)
+ if actual != expected:
+ raise ValueError(
+ f"bundle layout mismatch; missing={sorted(expected - actual)}, unexpected={sorted(actual - expected)}"
+ )
+ if (stage_dir / MARKER_PATH).read_bytes() != marker_data(metadata, architecture):
+ raise ValueError("bundle marker does not match pinned ConPTY metadata")
+ for item in metadata["bundles"][architecture]["files"]:
+ path = stage_dir / PurePosixPath(item["destination"])
+ actual_hash = sha256_file(path)
+ if actual_hash != item["sha256"]:
+ raise ValueError(f"staged file hash mismatch for {path}")
+ for notice in metadata["notices"]:
+ path = stage_dir / PurePosixPath(notice["destination"])
+ actual_hash = sha256_file(path)
+ if actual_hash != notice["sha256"]:
+ raise ValueError(f"staged notice hash mismatch for {path}")
+
+
+def archive_bundle(
+ metadata_path: Path, architecture: str, stage_dir: Path, output_path: Path
+) -> None:
+ validate_stage(metadata_path, architecture, stage_dir)
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ with zipfile.ZipFile(output_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
+ for path in sorted(stage_dir.rglob("*")):
+ if path.is_file():
+ archive.write(path, path.relative_to(stage_dir).as_posix())
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="Package Herdr with Microsoft's pinned ConPTY runtime")
+ parser.add_argument("--metadata", type=Path, default=DEFAULT_METADATA)
+ subparsers = parser.add_subparsers(dest="command", required=True)
+
+ stage = subparsers.add_parser("stage")
+ stage.add_argument("--architecture", choices=("x86_64",), default="x86_64")
+ stage.add_argument("--package", type=Path, required=True)
+ stage.add_argument("--herdr-exe", type=Path, required=True)
+ stage.add_argument("--output-dir", type=Path, required=True)
+
+ archive = subparsers.add_parser("archive")
+ archive.add_argument("--architecture", choices=("x86_64",), default="x86_64")
+ archive.add_argument("--stage-dir", type=Path, required=True)
+ archive.add_argument("--output", type=Path, required=True)
+ return parser.parse_args()
+
+
+def main() -> None:
+ args = parse_args()
+ if args.command == "stage":
+ stage_bundle(
+ args.metadata,
+ args.architecture,
+ args.package,
+ args.herdr_exe,
+ args.output_dir,
+ )
+ else:
+ archive_bundle(args.metadata, args.architecture, args.stage_dir, args.output)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/preview.py b/scripts/preview.py
index e173f865..4c9f0167 100644
--- a/scripts/preview.py
+++ b/scripts/preview.py
@@ -17,7 +17,7 @@ ASSET_TARGETS = (
)
EXPECTED_ASSET_NAMES = {
**{target: f"herdr-{target}" for target in ASSET_TARGETS},
- "windows-x86_64": "herdr-windows-x86_64.exe",
+ "windows-x86_64": "herdr-windows-x86_64.zip",
}
HIDDEN_SUBJECTS = (
"docs: update website manifest",
@@ -188,6 +188,10 @@ def asset_objects(urls: dict[str, str], shas: dict[str, str]) -> dict[str, dict[
sha = shas.get(target)
if sha:
entry["sha256"] = sha
+ if target.startswith("windows-"):
+ if not sha or not re.fullmatch(r"[0-9a-fA-F]{64}", sha):
+ raise ValueError(f"{target} requires a SHA-256 digest")
+ entry["format"] = "zip"
assets[target] = entry
return assets
diff --git a/scripts/test_package_windows_conpty.py b/scripts/test_package_windows_conpty.py
new file mode 100644
index 00000000..b8d73a6c
--- /dev/null
+++ b/scripts/test_package_windows_conpty.py
@@ -0,0 +1,171 @@
+from __future__ import annotations
+
+import hashlib
+import io
+import json
+import struct
+import tempfile
+import unittest
+import zipfile
+from pathlib import Path
+from unittest import mock
+
+from scripts import package_windows_conpty as package
+
+
+class WindowsConptyPackageTests(unittest.TestCase):
+ def test_pinned_metadata_and_notices_are_consistent(self) -> None:
+ metadata = package.load_metadata(package.DEFAULT_METADATA)
+ self.assertEqual(metadata["package"]["id"], "Microsoft.Windows.Console.ConPTY")
+ self.assertEqual(metadata["package"]["version"], "1.24.260710001")
+ self.assertEqual(
+ {item["destination"] for item in metadata["bundles"]["x86_64"]["files"]},
+ {
+ "conpty/conpty.dll",
+ "conpty/x64/OpenConsole.exe",
+ "conpty/arm64/OpenConsole.exe",
+ },
+ )
+ loader = (
+ package.PROJECT_ROOT / "vendor/portable-pty/src/win/psuedocon.rs"
+ ).read_text(encoding="utf-8")
+ installer = (package.PROJECT_ROOT / "website/install.ps1").read_text(
+ encoding="utf-8"
+ )
+ for item in metadata["bundles"]["x86_64"]["files"]:
+ self.assertIn(item["sha256"], loader)
+ self.assertNotIn(item["sha256"], installer)
+ self.assertIn('Get-Content -LiteralPath $markerPath -Raw', installer)
+ self.assertIn('$filesProperty.Value.PSObject.Properties[$relative]', installer)
+ for notice in metadata["notices"]:
+ source = package.PROJECT_ROOT / notice["source"]
+ self.assertEqual(package.sha256_file(source), notice["sha256"])
+
+ def test_powershell_wrapper_verifies_package_and_signatures(self) -> None:
+ wrapper = (package.PROJECT_ROOT / "scripts/package_windows_conpty.ps1").read_text(
+ encoding="utf-8"
+ )
+ self.assertIn('"nuget", "verify", "--all"', wrapper)
+ self.assertIn("Get-AuthenticodeSignature", wrapper)
+ self.assertIn('conpty\\arm64\\OpenConsole.exe', wrapper)
+ self.assertIn('conpty\\x64\\OpenConsole.exe', wrapper)
+ self.assertIn('conpty\\conpty.dll', wrapper)
+ self.assertIn('"*Microsoft Corporation*"', wrapper)
+
+ def test_package_download_has_a_finite_timeout(self) -> None:
+ payload = b"package"
+ with tempfile.TemporaryDirectory() as temporary:
+ destination = Path(temporary) / "conpty.nupkg"
+ metadata = {
+ "url": "https://example.invalid/conpty.nupkg",
+ "sha256": hashlib.sha256(payload).hexdigest(),
+ }
+ with mock.patch.object(
+ package.urllib.request, "urlopen", return_value=io.BytesIO(payload)
+ ) as urlopen:
+ package.acquire_package(metadata, destination)
+
+ urlopen.assert_called_once_with(
+ metadata["url"], timeout=package.DOWNLOAD_TIMEOUT_SECONDS
+ )
+
+ def test_stage_and_archive_validate_exact_package(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ dll = self._pe(0x8664, b"dll")
+ x64_host = self._pe(0x8664, b"x64")
+ arm64_host = self._pe(0xAA64, b"arm64")
+ files = [
+ self._file("runtimes/win-x64/native/conpty.dll", "conpty/conpty.dll", dll, "0x8664"),
+ self._file(
+ "build/native/runtimes/x64/OpenConsole.exe",
+ "conpty/x64/OpenConsole.exe",
+ x64_host,
+ "0x8664",
+ ),
+ self._file(
+ "build/native/runtimes/arm64/OpenConsole.exe",
+ "conpty/arm64/OpenConsole.exe",
+ arm64_host,
+ "0xaa64",
+ ),
+ ]
+ nupkg = root / "conpty.nupkg"
+ self._write_package(nupkg, files, {item["source"]: data for item, data in zip(files, (dll, x64_host, arm64_host))})
+ metadata_path = root / "conpty.json"
+ metadata_path.write_text(
+ json.dumps(
+ {
+ "schema_version": 1,
+ "package": {
+ "id": "Microsoft.Windows.Console.ConPTY",
+ "version": "1.24.260710001",
+ "url": nupkg.as_uri(),
+ "sha256": package.sha256_file(nupkg),
+ "license": "MIT",
+ },
+ "bundles": {"x86_64": {"files": files}},
+ "notices": [],
+ }
+ ),
+ encoding="utf-8",
+ )
+ herdr = root / "input-herdr.exe"
+ herdr.write_bytes(b"herdr")
+ stage = root / "stage"
+ package.stage_bundle(metadata_path, "x86_64", nupkg, herdr, stage)
+ package.validate_stage(metadata_path, "x86_64", stage)
+
+ output = root / "herdr.zip"
+ package.archive_bundle(metadata_path, "x86_64", stage, output)
+ with zipfile.ZipFile(output) as archive:
+ self.assertEqual(
+ set(archive.namelist()),
+ package.expected_stage_files(
+ package.load_metadata(metadata_path), "x86_64"
+ ),
+ )
+
+ (stage / "conpty" / "conpty.dll").write_bytes(b"tampered")
+ with self.assertRaisesRegex(ValueError, "staged file hash mismatch"):
+ package.validate_stage(metadata_path, "x86_64", stage)
+
+ @staticmethod
+ def _pe(machine: int, payload: bytes) -> bytes:
+ data = bytearray(0x80)
+ data[:2] = b"MZ"
+ struct.pack_into("<I", data, 0x3C, 0x40)
+ data[0x40:0x44] = b"PE\0\0"
+ struct.pack_into("<H", data, 0x44, machine)
+ return bytes(data) + payload
+
+ @staticmethod
+ def _file(source: str, destination: str, data: bytes, machine: str) -> dict[str, str]:
+ return {
+ "source": source,
+ "destination": destination,
+ "sha256": hashlib.sha256(data).hexdigest(),
+ "pe_machine": machine,
+ }
+
+ @staticmethod
+ def _write_package(
+ path: Path, files: list[dict[str, str]], payloads: dict[str, bytes]
+ ) -> None:
+ nuspec = """<?xml version="1.0" encoding="utf-8"?>
+<package xmlns="http://schemas.microsoft.com/packaging/2011/08/nuspec.xsd">
+ <metadata>
+ <id>Microsoft.Windows.Console.ConPTY</id>
+ <version>1.24.260710001</version>
+ <license type="expression">MIT</license>
+ </metadata>
+</package>
+"""
+ with zipfile.ZipFile(path, "w") as archive:
+ archive.writestr("Microsoft.Windows.Console.ConPTY.nuspec", nuspec)
+ for item in files:
+ archive.writestr(item["source"], payloads[item["source"]])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/test_preview.py b/scripts/test_preview.py
index e11af67c..7b11d4c7 100644
--- a/scripts/test_preview.py
+++ b/scripts/test_preview.py
@@ -39,7 +39,10 @@ class PreviewNotesTests(unittest.TestCase):
base_version="0.6.6",
protocol=12,
notes=notes,
- shas={"linux-x86_64": "deadbeef"},
+ shas={
+ "linux-x86_64": "deadbeef",
+ "windows-x86_64": "a" * 64,
+ },
retain=30,
)
data = json.loads(content)
@@ -51,10 +54,32 @@ class PreviewNotesTests(unittest.TestCase):
)
self.assertEqual(
data["assets"]["windows-x86_64"]["url"],
- "https://github.com/ogulcancelik/herdr/releases/download/preview-2026-06-02-abcdef123456/herdr-windows-x86_64.exe",
+ "https://github.com/ogulcancelik/herdr/releases/download/preview-2026-06-02-abcdef123456/herdr-windows-x86_64.zip",
)
+ self.assertEqual(
+ data["assets"]["windows-x86_64"]["sha256"],
+ "a" * 64,
+ )
+ self.assertEqual(data["assets"]["windows-x86_64"]["format"], "zip")
self.assertIn("2026-06-02-abcdef123456", data["builds"])
+ def test_windows_preview_asset_requires_sha256(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ with self.assertRaisesRegex(ValueError, "windows-x86_64 requires"):
+ preview.build_manifest(
+ output=Path(tmp) / "preview.json",
+ repo="ogulcancelik/herdr",
+ tag="preview-test",
+ build_id="test",
+ commit="abcdef",
+ built_at="2026-06-02T03:00:00Z",
+ base_version="0.6.6",
+ protocol=12,
+ notes="test",
+ shas={},
+ retain=1,
+ )
+
def test_hidden_subjects_include_preview_manifest_commits(self):
self.assertTrue(preview.hidden_subject("docs: update preview manifest"))
self.assertTrue(preview.hidden_subject("docs: update website manifest"))
diff --git a/scripts/test_vendor_portable_pty.py b/scripts/test_vendor_portable_pty.py
index 87537d41..cf3b791a 100644
--- a/scripts/test_vendor_portable_pty.py
+++ b/scripts/test_vendor_portable_pty.py
@@ -103,13 +103,22 @@ class VendorPortablePtyTests(unittest.TestCase):
f"stderr:\n{result.stderr}",
)
- def test_windows_conpty_loader_does_not_probe_path_conpty_dll(self) -> None:
+ def test_windows_conpty_loader_uses_only_controlled_sources(self) -> None:
project_root = Path(__file__).resolve().parent.parent
source = project_root / "vendor" / "portable-pty" / "src" / "win" / "psuedocon.rs"
text = source.read_text()
- self.assertIn('ConPtyFuncs::open(Path::new("kernel32.dll"))', text)
+ self.assertIn("std::env::current_exe()", text)
+ self.assertIn('.join("conpty")', text)
+ self.assertIn('"x64/OpenConsole.exe"', text)
+ self.assertIn('"arm64/OpenConsole.exe"', text)
+ self.assertIn("LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR", text)
+ self.assertIn("LOAD_LIBRARY_SEARCH_SYSTEM32", text)
+ self.assertIn("GetModuleHandleW", text)
+ self.assertIn("HERDR_WINDOWS_CONPTY", text)
+ self.assertIn("Sha256::new()", text)
self.assertNotIn('Path::new("conpty.dll")', text)
+ self.assertNotIn("shared_library", text)
if __name__ == "__main__":
diff --git a/scripts/windows_conpty_enhanced_input_probe.ps1 b/scripts/windows_conpty_enhanced_input_probe.ps1
new file mode 100644
index 00000000..bbd2b40b
--- /dev/null
+++ b/scripts/windows_conpty_enhanced_input_probe.ps1
@@ -0,0 +1,418 @@
+param(
+ [Parameter(Mandatory = $true)]
+ [string] $ExePath,
+
+ [string] $Session = "conpty-input-$([guid]::NewGuid().ToString('N'))",
+
+ [string] $ExpectedConsoleHostPath = ""
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = "Stop"
+
+function Invoke-Checked {
+ param([string] $Command, [string[]] $Arguments)
+ & $Command @Arguments
+ if ($LASTEXITCODE -ne 0) {
+ throw "command failed with exit code $LASTEXITCODE`: $Command $($Arguments -join ' ')"
+ }
+}
+
+function Read-Pane {
+ param([string] $PaneId)
+ $output = & $script:Exe pane read $PaneId --source recent-unwrapped --lines 200 --format text 2>&1
+ if ($LASTEXITCODE -ne 0) {
+ throw "pane read failed with exit code $LASTEXITCODE`: $($output -join "`n")"
+ }
+ return $output -join "`n"
+}
+
+function Wait-PaneText {
+ param(
+ [string] $PaneId,
+ [string] $Needle,
+ [int] $TimeoutSeconds = 10
+ )
+ $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
+ do {
+ $text = Read-Pane -PaneId $PaneId
+ if ($text.Contains($Needle)) {
+ return $text
+ }
+ Start-Sleep -Milliseconds 200
+ } while ((Get-Date) -lt $deadline)
+ return $text
+}
+
+function New-ProbePane {
+ param([string] $Mode)
+ $created = & $script:Exe workspace create --cwd $PWD.Path 2>&1
+ if ($LASTEXITCODE -ne 0) {
+ throw "workspace create failed with exit code $LASTEXITCODE`: $($created -join "`n")"
+ }
+ $paneId = (($created -join "`n") | ConvertFrom-Json).result.root_pane.pane_id
+ if ([string]::IsNullOrWhiteSpace($paneId)) {
+ throw "workspace create did not return a root pane id: $($created -join "`n")"
+ }
+
+ $command = "& '$script:ProbeExe' $Mode"
+ Invoke-Checked $script:Exe @("pane", "run", $paneId, $command)
+ $ready = "PROBE_READY_$($Mode.ToUpperInvariant())"
+ $text = Wait-PaneText -PaneId $paneId -Needle $ready
+ if (-not $text.Contains($ready)) {
+ throw "$Mode probe did not become ready: $text"
+ }
+ return $paneId
+}
+
+function Get-LatestProbeHex {
+ param([string] $PaneText)
+ $hexMatches = [regex]::Matches($PaneText, "PROBE_ALL:([0-9a-f]+)")
+ if ($hexMatches.Count -eq 0) {
+ return ""
+ }
+ return $hexMatches[$hexMatches.Count - 1].Groups[1].Value
+}
+
+function Wait-PaneHexAppend {
+ param(
+ [string] $PaneId,
+ [string] $PreviousHex,
+ [string] $ExpectedHex,
+ [int] $TimeoutSeconds = 4
+ )
+ $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
+ do {
+ $paneText = Read-Pane -PaneId $PaneId
+ $latestHex = Get-LatestProbeHex -PaneText $paneText
+ if ($latestHex.StartsWith($PreviousHex)) {
+ $appendedHex = $latestHex.Substring($PreviousHex.Length)
+ if ($appendedHex -eq $ExpectedHex) {
+ return [ordered]@{ delivered = $true; pane = $paneText }
+ }
+ }
+ Start-Sleep -Milliseconds 200
+ } while ((Get-Date) -lt $deadline)
+ return [ordered]@{ delivered = $false; pane = $paneText }
+}
+
+function Send-KeyAndObserve {
+ param(
+ [string] $PaneId,
+ [string] $Key,
+ [string] $ExpectedHex
+ )
+ $before = Get-LatestProbeHex -PaneText (Read-Pane -PaneId $PaneId)
+ Invoke-Checked $script:Exe @("pane", "send-keys", $PaneId, $Key)
+ $observed = Wait-PaneHexAppend -PaneId $PaneId -PreviousHex $before -ExpectedHex $ExpectedHex
+ return [ordered]@{
+ key = $Key
+ expected_hex = $ExpectedHex
+ delivered = $observed.delivered
+ pane = $observed.pane
+ }
+}
+
+function Send-RawAndObserve {
+ param(
+ [string] $PaneId,
+ [string] $Text,
+ [string] $ExpectedHex
+ )
+ $before = Get-LatestProbeHex -PaneText (Read-Pane -PaneId $PaneId)
+ & $script:Exe pane send-text $PaneId $Text | Out-Null
+ if ($LASTEXITCODE -ne 0) {
+ throw "pane send-text failed with exit code $LASTEXITCODE"
+ }
+ $observed = Wait-PaneHexAppend -PaneId $PaneId -PreviousHex $before -ExpectedHex $ExpectedHex
+ return [ordered]@{
+ expected_hex = $ExpectedHex
+ delivered = $observed.delivered
+ pane = $observed.pane
+ }
+}
+
+$script:Exe = (Resolve-Path $ExePath).Path
+$workDir = Join-Path ([System.IO.Path]::GetTempPath()) "herdr-conpty-input-$([guid]::NewGuid().ToString('N'))"
+$probeSource = Join-Path $workDir "probe.rs"
+$script:ProbeExe = Join-Path $workDir "probe.exe"
+$oldSession = $env:HERDR_SESSION
+$oldSocket = $env:HERDR_SOCKET_PATH
+$oldClientSocket = $env:HERDR_CLIENT_SOCKET_PATH
+$server = $null
+$report = [ordered]@{}
+$failed = $false
+$initialConsoleHostIds = @(Get-Process -Name conhost, OpenConsole -ErrorAction SilentlyContinue | ForEach-Object { $_.Id })
+$expectedConsoleHost = if ([string]::IsNullOrWhiteSpace($ExpectedConsoleHostPath)) {
+ $null
+} else {
+ (Resolve-Path $ExpectedConsoleHostPath).Path
+}
+
+try {
+ New-Item -ItemType Directory -Force -Path $workDir | Out-Null
+@'
+use std::ffi::c_void;
+
+const STD_INPUT_HANDLE: u32 = -10i32 as u32;
+const STD_OUTPUT_HANDLE: u32 = -11i32 as u32;
+const ENABLE_PROCESSED_INPUT: u32 = 0x0001;
+const ENABLE_LINE_INPUT: u32 = 0x0002;
+const ENABLE_ECHO_INPUT: u32 = 0x0004;
+const ENABLE_VIRTUAL_TERMINAL_INPUT: u32 = 0x0200;
+
+type Handle = *mut c_void;
+
+#[link(name = "Kernel32")]
+extern "system" {
+ fn GetStdHandle(kind: u32) -> Handle;
+ fn GetConsoleMode(handle: Handle, mode: *mut u32) -> i32;
+ fn SetConsoleMode(handle: Handle, mode: u32) -> i32;
+ fn ReadFile(
+ handle: Handle,
+ buffer: *mut c_void,
+ bytes_to_read: u32,
+ bytes_read: *mut u32,
+ overlapped: *mut c_void,
+ ) -> i32;
+ fn WriteFile(
+ handle: Handle,
+ buffer: *const c_void,
+ bytes_to_write: u32,
+ bytes_written: *mut u32,
+ overlapped: *mut c_void,
+ ) -> i32;
+}
+
+fn write_all(handle: Handle, mut bytes: &[u8]) {
+ while !bytes.is_empty() {
+ let mut written = 0;
+ let ok = unsafe {
+ WriteFile(
+ handle,
+ bytes.as_ptr().cast(),
+ bytes.len() as u32,
+ &mut written,
+ std::ptr::null_mut(),
+ )
+ };
+ if ok == 0 || written == 0 {
+ std::process::exit(3);
+ }
+ bytes = &bytes[written as usize..];
+ }
+}
+
+fn main() {
+ let mode = std::env::args().nth(1).unwrap_or_else(|| "legacy".to_string());
+ let input = unsafe { GetStdHandle(STD_INPUT_HANDLE) };
+ let output = unsafe { GetStdHandle(STD_OUTPUT_HANDLE) };
+ let mut console_mode = 0;
+ if unsafe { GetConsoleMode(input, &mut console_mode) } == 0 {
+ write_all(output, b"PROBE_ERROR_GET_MODE\r\n");
+ std::process::exit(1);
+ }
+ let raw_vt_mode = (console_mode | ENABLE_VIRTUAL_TERMINAL_INPUT)
+ & !(ENABLE_PROCESSED_INPUT | ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT);
+ if unsafe { SetConsoleMode(input, raw_vt_mode) } == 0 {
+ write_all(output, b"PROBE_ERROR_SET_MODE\r\n");
+ std::process::exit(2);
+ }
+
+ if mode == "kitty" {
+ write_all(output, b"\x1b[>7u\x1b[?u\x1b[cPROBE_READY_KITTY\r\n");
+ } else {
+ write_all(output, b"PROBE_READY_LEGACY\r\n");
+ }
+
+ let mut all = Vec::new();
+ let mut buffer = [0u8; 256];
+ loop {
+ let mut read = 0;
+ let ok = unsafe {
+ ReadFile(
+ input,
+ buffer.as_mut_ptr().cast(),
+ buffer.len() as u32,
+ &mut read,
+ std::ptr::null_mut(),
+ )
+ };
+ if ok == 0 || read == 0 {
+ break;
+ }
+ all.extend_from_slice(&buffer[..read as usize]);
+ let mut line = String::from("PROBE_ALL:");
+ for byte in &all {
+ use std::fmt::Write as _;
+ let _ = write!(&mut line, "{byte:02x}");
+ }
+ line.push_str("\r\n");
+ write_all(output, line.as_bytes());
+ }
+}
+'@ | Set-Content -NoNewline -Encoding utf8 $probeSource
+
+ Invoke-Checked rustc @("--edition", "2021", $probeSource, "-o", $script:ProbeExe)
+
+ $env:HERDR_SESSION = $Session
+ Remove-Item Env:HERDR_SOCKET_PATH -ErrorAction SilentlyContinue
+ Remove-Item Env:HERDR_CLIENT_SOCKET_PATH -ErrorAction SilentlyContinue
+
+ $os = Get-CimInstance Win32_OperatingSystem
+ $report.os = [ordered]@{
+ caption = $os.Caption
+ version = $os.Version
+ build = $os.BuildNumber
+ }
+
+ Invoke-Checked $script:Exe @("--version")
+ & $script:Exe --default-config | Out-Null
+ if ($LASTEXITCODE -ne 0) {
+ throw "command failed with exit code $LASTEXITCODE`: $script:Exe --default-config"
+ }
+
+ $server = Start-Process -FilePath $script:Exe -ArgumentList "server" -PassThru -WindowStyle Hidden
+ $deadline = (Get-Date).AddSeconds(10)
+ $serverReady = $false
+ do {
+ Start-Sleep -Milliseconds 250
+ $status = & $script:Exe status server 2>&1
+ if ($LASTEXITCODE -eq 0 -and (($status -join "`n") -match "status: running")) {
+ $serverReady = $true
+ break
+ }
+ } while ((Get-Date) -lt $deadline)
+ if (-not $serverReady) {
+ throw "server did not become ready: $($status -join "`n")"
+ }
+
+ $legacyPane = New-ProbePane -Mode "legacy"
+ $report.legacy_alt_v = Send-KeyAndObserve -PaneId $legacyPane -Key "alt+v" -ExpectedHex "1b76"
+
+ $kittyPane = New-ProbePane -Mode "kitty"
+ $report.kitty_initial = Wait-PaneText -PaneId $kittyPane -Needle "1b5b3f3775"
+ $kittyInitialHex = Get-LatestProbeHex -PaneText $report.kitty_initial
+ $report.device_attributes_response = $kittyInitialHex -match "1b5b3f(?:3[0-9]|3b)+63"
+ $report.kitty_query_response = $kittyInitialHex.Contains("1b5b3f3775")
+ $report.kitty_alt_v = Send-KeyAndObserve -PaneId $kittyPane -Key "alt+v" -ExpectedHex "1b5b3131383b333a3175"
+ $report.kitty_ctrl_u = Send-KeyAndObserve -PaneId $kittyPane -Key "ctrl+u" -ExpectedHex "1b5b3131373b353a3175"
+ $report.kitty_ctrl_v = Send-KeyAndObserve -PaneId $kittyPane -Key "ctrl+v" -ExpectedHex "1b5b3131383b353a3175"
+ $report.kitty_shift_enter = Send-KeyAndObserve -PaneId $kittyPane -Key "shift+enter" -ExpectedHex "1b5b31333b3275"
+ $report.kitty_ctrl_backspace = Send-KeyAndObserve -PaneId $kittyPane -Key "ctrl+backspace" -ExpectedHex "1b5b3132373b3575"
+ $report.kitty_up = Send-KeyAndObserve -PaneId $kittyPane -Key "up" -ExpectedHex "1b5b313b313a3141"
+ $report.kitty_escape = Send-KeyAndObserve -PaneId $kittyPane -Key "esc" -ExpectedHex "1b5b323775"
+ $report.raw_kitty_alt_v = Send-RawAndObserve -PaneId $kittyPane -Text ([char]27 + "[118;3:1u") -ExpectedHex "1b5b3131383b333a3175"
+ $report.raw_kitty_ctrl_u = Send-RawAndObserve -PaneId $kittyPane -Text ([char]27 + "[117;5:1u") -ExpectedHex "1b5b3131373b353a3175"
+ $report.raw_kitty_ctrl_v = Send-RawAndObserve -PaneId $kittyPane -Text ([char]27 + "[118;5:1u") -ExpectedHex "1b5b3131383b353a3175"
+ $report.raw_kitty_shift_enter = Send-RawAndObserve -PaneId $kittyPane -Text ([char]27 + "[13;2u") -ExpectedHex "1b5b31333b3275"
+ $report.raw_kitty_ctrl_backspace = Send-RawAndObserve -PaneId $kittyPane -Text ([char]27 + "[127;5u") -ExpectedHex "1b5b3132373b3575"
+ $report.raw_kitty_ctrl_delete = Send-RawAndObserve -PaneId $kittyPane -Text ([char]27 + "[57426;5u") -ExpectedHex "1b5b35373432363b3575"
+ $report.raw_alt_v = Send-RawAndObserve -PaneId $kittyPane -Text ([char]27 + "v") -ExpectedHex "1b76"
+ $report.raw_ctrl_u = Send-RawAndObserve -PaneId $kittyPane -Text ([string][char]0x15) -ExpectedHex "15"
+
+ $consoleHosts = @(Get-Process -Name conhost, OpenConsole -ErrorAction SilentlyContinue |
+ Where-Object { $initialConsoleHostIds -notcontains $_.Id } |
+ ForEach-Object {
+ $path = ""
+ $version = ""
+ try { $path = $_.Path } catch {}
+ try { $version = $_.MainModule.FileVersionInfo.FileVersion } catch {}
+ [ordered]@{
+ id = $_.Id
+ name = $_.ProcessName
+ path = $path
+ version = $version
+ }
+ })
+ $report.new_console_hosts = $consoleHosts
+ $appLocalHostRequired = $null -ne $expectedConsoleHost
+ if ($appLocalHostRequired) {
+ $report.expected_console_host = $expectedConsoleHost
+ $report.app_local_console_host = @($consoleHosts | Where-Object {
+ $_.name -ieq "OpenConsole" -and $_.path -ieq $expectedConsoleHost
+ }).Count -gt 0
+ } else {
+ $report.app_local_console_host = $null
+ }
+
+ $failed = -not $report.legacy_alt_v.delivered `
+ -or -not $report.device_attributes_response `
+ -or -not $report.kitty_query_response `
+ -or -not $report.kitty_alt_v.delivered `
+ -or -not $report.kitty_ctrl_u.delivered `
+ -or -not $report.kitty_ctrl_v.delivered `
+ -or -not $report.kitty_shift_enter.delivered `
+ -or -not $report.kitty_ctrl_backspace.delivered `
+ -or -not $report.kitty_up.delivered `
+ -or -not $report.kitty_escape.delivered `
+ -or -not $report.raw_kitty_alt_v.delivered `
+ -or -not $report.raw_kitty_ctrl_u.delivered `
+ -or -not $report.raw_kitty_ctrl_v.delivered `
+ -or -not $report.raw_kitty_shift_enter.delivered `
+ -or -not $report.raw_kitty_ctrl_backspace.delivered `
+ -or -not $report.raw_kitty_ctrl_delete.delivered `
+ -or -not $report.raw_alt_v.delivered `
+ -or -not $report.raw_ctrl_u.delivered `
+ -or ($appLocalHostRequired -and -not $report.app_local_console_host)
+} finally {
+ if ($null -ne $server) {
+ try {
+ $stopOutput = & $script:Exe server stop 2>&1
+ if ($LASTEXITCODE -ne 0) {
+ Write-Host "server stop during cleanup failed: $($stopOutput -join "`n")"
+ }
+ } catch {
+ Write-Host "server stop during cleanup failed: $($_.Exception.Message)"
+ }
+ Wait-Process -Id $server.Id -Timeout 10 -ErrorAction SilentlyContinue
+ $server.Refresh()
+ if (-not $server.HasExited) {
+ & taskkill.exe /PID $server.Id /T /F 2>&1 | Out-Null
+ }
+ }
+ if ($null -ne $expectedConsoleHost) {
+ $hostExitDeadline = (Get-Date).AddSeconds(5)
+ do {
+ $remainingHosts = @(Get-Process -Name OpenConsole -ErrorAction SilentlyContinue |
+ Where-Object { $initialConsoleHostIds -notcontains $_.Id } |
+ Where-Object {
+ $path = ""
+ try { $path = $_.Path } catch {}
+ $path -ieq $expectedConsoleHost
+ })
+ if ($remainingHosts.Count -eq 0) {
+ break
+ }
+ Start-Sleep -Milliseconds 100
+ } while ((Get-Date) -lt $hostExitDeadline)
+ $report.app_local_console_host_exited = $remainingHosts.Count -eq 0
+ if (-not $report.app_local_console_host_exited) {
+ $failed = $true
+ }
+ }
+ $global:LASTEXITCODE = 0
+ if ($null -eq $oldSession) {
+ Remove-Item Env:HERDR_SESSION -ErrorAction SilentlyContinue
+ } else {
+ $env:HERDR_SESSION = $oldSession
+ }
+ if ($null -eq $oldSocket) {
+ Remove-Item Env:HERDR_SOCKET_PATH -ErrorAction SilentlyContinue
+ } else {
+ $env:HERDR_SOCKET_PATH = $oldSocket
+ }
+ if ($null -eq $oldClientSocket) {
+ Remove-Item Env:HERDR_CLIENT_SOCKET_PATH -ErrorAction SilentlyContinue
+ } else {
+ $env:HERDR_CLIENT_SOCKET_PATH = $oldClientSocket
+ }
+
+ $json = $report | ConvertTo-Json -Depth 8
+ Write-Host $json
+ Remove-Item -Recurse -Force $workDir -ErrorAction SilentlyContinue
+}
+
+if ($failed) {
+ throw "enhanced Windows ConPTY input probe failed; see the JSON report above"
+}
diff --git a/scripts/windows_install_conpty_package_test.ps1 b/scripts/windows_install_conpty_package_test.ps1
new file mode 100644
index 00000000..062c2cfe
--- /dev/null
+++ b/scripts/windows_install_conpty_package_test.ps1
@@ -0,0 +1,146 @@
+param(
+ [Parameter(Mandatory = $true)]
+ [string]$ArchivePath
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = "Stop"
+
+$archive = (Resolve-Path -LiteralPath $ArchivePath).Path
+$root = Join-Path $env:RUNNER_TEMP ("herdr-installer-test-" + [Guid]::NewGuid().ToString("N"))
+$webRoot = Join-Path $root "web"
+$herdrHome = Join-Path $root "home"
+$installDir = Join-Path $root "bin"
+New-Item -ItemType Directory -Force -Path $webRoot | Out-Null
+Copy-Item -LiteralPath $archive -Destination (Join-Path $webRoot "herdr-windows-x86_64.zip")
+$hash = (Get-FileHash -Algorithm SHA256 $archive).Hash.ToLowerInvariant()
+
+$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
+$listener.Start()
+$port = ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port
+$listener.Stop()
+$manifest = @{
+ channel = "preview"
+ base_version = "0.0.0"
+ build_id = "installer-test"
+ assets = @{
+ "windows-x86_64" = @{
+ url = "http://127.0.0.1:$port/herdr-windows-x86_64.zip"
+ sha256 = $hash
+ format = "zip"
+ }
+ }
+} | ConvertTo-Json -Depth 5
+$manifestPath = Join-Path $webRoot "preview.json"
+$manifest | Out-File -LiteralPath $manifestPath -Encoding utf8
+
+$server = $null
+$oldHerdrHome = $env:HERDR_HOME
+try {
+ $server = Start-Process python -ArgumentList @("-m", "http.server", "$port", "--bind", "127.0.0.1", "--directory", $webRoot) -PassThru -WindowStyle Hidden
+ $env:HERDR_HOME = Join-Path $root "unused\..\home"
+ $manifestUrl = "http://127.0.0.1:$port/preview.json"
+ for ($attempt = 0; $attempt -lt 20; $attempt++) {
+ try {
+ Invoke-WebRequest -Uri $manifestUrl -UseBasicParsing | Out-Null
+ break
+ } catch {
+ if ($attempt -eq 19) { throw }
+ Start-Sleep -Milliseconds 100
+ }
+ }
+
+ & "$PSScriptRoot\..\website\install.ps1" `
+ -ManifestUrl $manifestUrl `
+ -InstallDir $installDir `
+ -ExpectedBuildId "installer-test"
+
+ $required = @(
+ "herdr.exe",
+ "conpty\herdr-conpty.json",
+ "conpty\conpty.dll",
+ "conpty\x64\OpenConsole.exe",
+ "conpty\arm64\OpenConsole.exe",
+ "THIRD-PARTY-NOTICES\Microsoft.Windows.Console.ConPTY-LICENSE.txt",
+ "THIRD-PARTY-NOTICES\Microsoft.Windows.Console.ConPTY-NOTICE.md"
+ )
+ foreach ($relative in $required) {
+ if (-not (Test-Path -LiteralPath (Join-Path $installDir $relative) -PathType Leaf)) {
+ throw "installer did not activate required file $relative"
+ }
+ }
+
+ $releaseDir = Get-ChildItem -LiteralPath (Join-Path $herdrHome "packages\standalone\releases") -Directory |
+ Where-Object { -not $_.Name.StartsWith(".staging.") } |
+ Select-Object -First 1
+ if ($null -eq $releaseDir) {
+ throw "installer did not create a versioned release"
+ }
+ Remove-Item -LiteralPath (Join-Path $releaseDir.FullName "conpty\conpty.dll") -Force
+
+ $badManifest = $manifest | ConvertFrom-Json
+ $badManifest.assets."windows-x86_64".url = "http://127.0.0.1:$port/missing.zip"
+ $badManifest | ConvertTo-Json -Depth 5 | Out-File -LiteralPath $manifestPath -Encoding utf8
+ $downloadFailed = $false
+ try {
+ & "$PSScriptRoot\..\website\install.ps1" `
+ -ManifestUrl $manifestUrl `
+ -InstallDir $installDir `
+ -ExpectedBuildId "installer-test"
+ } catch {
+ if ($_.Exception.Message -notmatch "(?i)(404|not found)") {
+ throw
+ }
+ $downloadFailed = $true
+ }
+ if (-not $downloadFailed) {
+ throw "installer repair unexpectedly accepted a missing archive"
+ }
+ if (-not (Test-Path -LiteralPath (Join-Path $releaseDir.FullName "herdr.exe") -PathType Leaf)) {
+ throw "failed repair removed the existing release"
+ }
+
+ $manifest | Out-File -LiteralPath $manifestPath -Encoding utf8
+ & "$PSScriptRoot\..\website\install.ps1" `
+ -ManifestUrl $manifestUrl `
+ -InstallDir $installDir `
+ -ExpectedBuildId "installer-test"
+ if (-not (Test-Path -LiteralPath (Join-Path $installDir "conpty\conpty.dll") -PathType Leaf)) {
+ throw "installer did not repair an incomplete release"
+ }
+
+ $x64HostDir = Join-Path $releaseDir.FullName "conpty\x64"
+ $junctionTarget = Join-Path $root "junction-target"
+ Move-Item -LiteralPath $x64HostDir -Destination $junctionTarget
+ New-Item -ItemType Junction -Path $x64HostDir -Target $junctionTarget | Out-Null
+ & "$PSScriptRoot\..\website\install.ps1" `
+ -ManifestUrl $manifestUrl `
+ -InstallDir $installDir `
+ -ExpectedBuildId "installer-test"
+ $repairedHostDir = Get-Item -LiteralPath (Join-Path $installDir "conpty\x64") -Force
+ if ($repairedHostDir.Attributes -band [IO.FileAttributes]::ReparsePoint) {
+ throw "installer accepted a reparse-point ConPTY directory"
+ }
+
+ $rejected = $false
+ try {
+ & "$PSScriptRoot\..\website\install.ps1" `
+ -ManifestUrl $manifestUrl `
+ -InstallDir $installDir `
+ -ExpectedBuildId "different-build"
+ } catch {
+ if ($_.Exception.Message -notlike "Preview manifest changed while updating.*") {
+ throw
+ }
+ $rejected = $true
+ }
+ if (-not $rejected) {
+ throw "installer accepted a manifest that did not match the updater-selected build"
+ }
+} finally {
+ $env:HERDR_HOME = $oldHerdrHome
+ if ($null -ne $server -and -not $server.HasExited) {
+ Stop-Process -Id $server.Id -Force -ErrorAction SilentlyContinue
+ }
+ Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue
+}
diff --git a/scripts/windows_smoke_conpty_path.ps1 b/scripts/windows_smoke_conpty_path.ps1
index ce54dd45..bec93b66 100644
--- a/scripts/windows_smoke_conpty_path.ps1
+++ b/scripts/windows_smoke_conpty_path.ps1
@@ -59,8 +59,11 @@ Invoke-Checked rustc @("--crate-type", "cdylib", "--edition", "2021", $fakeSourc
$oldPath = $env:PATH
$oldSession = $env:HERDR_SESSION
+$oldSocket = $env:HERDR_SOCKET_PATH
+$oldClientSocket = $env:HERDR_CLIENT_SOCKET_PATH
$env:PATH = "$fakeDir;$oldPath"
$env:HERDR_SESSION = $Session
+Remove-Item Env:HERDR_SOCKET_PATH, Env:HERDR_CLIENT_SOCKET_PATH -ErrorAction SilentlyContinue
$server = $null
try {
@@ -84,9 +87,16 @@ try {
throw "server did not become ready"
}
- $created = & $exe workspace create --cwd $PWD.Path 2>&1
- if ($LASTEXITCODE -ne 0) {
- throw "workspace create failed with exit code $LASTEXITCODE`: $($created -join "`n")"
+ $savedErrorActionPreference = $ErrorActionPreference
+ try {
+ $ErrorActionPreference = "Continue"
+ $created = & $exe workspace create --cwd $PWD.Path 2>&1
+ $createdExitCode = $LASTEXITCODE
+ } finally {
+ $ErrorActionPreference = $savedErrorActionPreference
+ }
+ if ($createdExitCode -ne 0) {
+ throw "workspace create failed with exit code $createdExitCode`: $($created -join "`n")"
}
$paneId = (($created -join "`n") | ConvertFrom-Json).result.root_pane.pane_id
if ([string]::IsNullOrWhiteSpace($paneId)) {
@@ -126,9 +136,27 @@ try {
Write-Host "server stop during cleanup failed: $($_.Exception.Message)"
}
Wait-Process -Id $server.Id -Timeout 10 -ErrorAction SilentlyContinue
+ $server.Refresh()
+ if (-not $server.HasExited) {
+ & taskkill.exe /PID $server.Id /T /F 2>&1 | Out-Null
+ }
}
$global:LASTEXITCODE = 0
$env:PATH = $oldPath
- $env:HERDR_SESSION = $oldSession
+ if ($null -eq $oldSession) {
+ Remove-Item Env:HERDR_SESSION -ErrorAction SilentlyContinue
+ } else {
+ $env:HERDR_SESSION = $oldSession
+ }
+ if ($null -eq $oldSocket) {
+ Remove-Item Env:HERDR_SOCKET_PATH -ErrorAction SilentlyContinue
+ } else {
+ $env:HERDR_SOCKET_PATH = $oldSocket
+ }
+ if ($null -eq $oldClientSocket) {
+ Remove-Item Env:HERDR_CLIENT_SOCKET_PATH -ErrorAction SilentlyContinue
+ } else {
+ $env:HERDR_CLIENT_SOCKET_PATH = $oldClientSocket
+ }
Remove-Item -Recurse -Force $fakeDir -ErrorAction SilentlyContinue
}