From a3456765ed3032ac0b52387e9920958ae8155136 Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 24 Sep 2026 23:49:18 -0400 Subject: [PATCH] fix(desktop-update): give Windows update steps NUL stdin, not the hand-off console Steps inherited the hand-off console's stdin, so any step that asks a question blocked forever. Its prompt went to the captured stdout, which is shown only after the step exits. `gateway start --all` did exactly this: it saw an interactive console and asked "Install it now so the gateway starts on login?". The update stopped after `hermes update exit code: 0` and never relaunched the app. Steps now read NUL. Prompts see a non-interactive stdin and take their defaults. The working-directory self-test also checks that a step's stdin is not a console, and a new test runs it under a real console. --- scripts/desktop-update/windows.ps1 | 29 ++++++++++++---- .../test_desktop_update_windows_cwd.py | 33 +++++++++++++++++++ 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/scripts/desktop-update/windows.ps1 b/scripts/desktop-update/windows.ps1 index c4f42760d8..b4645aac15 100644 --- a/scripts/desktop-update/windows.ps1 +++ b/scripts/desktop-update/windows.ps1 @@ -898,6 +898,12 @@ public static class HermesUpdateJob { [DllImport("kernel32.dll", SetLastError = true)] private static extern bool SetHandleInformation(IntPtr handle, int mask, int flags); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr CreateFile( + string fileName, uint desiredAccess, uint shareMode, ref SecurityAttributes attributes, + uint creationDisposition, uint flagsAndAttributes, IntPtr templateFile + ); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern bool CreateProcess( string applicationName, StringBuilder commandLine, @@ -912,9 +918,6 @@ public static class HermesUpdateJob { [DllImport("kernel32.dll", SetLastError = true)] private static extern bool TerminateProcess(IntPtr process, uint exitCode); - [DllImport("kernel32.dll")] - private static extern IntPtr GetStdHandle(int standardHandle); - [DllImport("kernel32.dll", SetLastError = true)] private static extern bool TerminateJobObject(IntPtr job, uint exitCode); @@ -934,6 +937,7 @@ public static class HermesUpdateJob { IntPtr job = IntPtr.Zero; IntPtr outRead = IntPtr.Zero, outWrite = IntPtr.Zero; IntPtr errRead = IntPtr.Zero, errWrite = IntPtr.Zero; + IntPtr nullInput = new IntPtr(-1); ProcessInformation pi = new ProcessInformation(); try { job = CreateJobObject(IntPtr.Zero, null); @@ -946,11 +950,17 @@ public static class HermesUpdateJob { throw new InvalidOperationException("CreatePipe failed"); if (!SetHandleInformation(outRead, 1, 0) || !SetHandleInformation(errRead, 1, 0)) throw new InvalidOperationException("SetHandleInformation failed"); + // Steps read NUL, never the hand-off console. A step that sees a + // console asks its question into the captured stdout, where the + // user cannot see it, and waits for an answer that never comes. + nullInput = CreateFile("NUL", 0x80000000, 0x00000003, ref sa, 3, 0, IntPtr.Zero); + if (nullInput == new IntPtr(-1)) + throw new InvalidOperationException("CreateFile(NUL) failed"); StartupInfo si = new StartupInfo(); si.Size = Marshal.SizeOf(typeof(StartupInfo)); si.Flags = 0x00000100; // STARTF_USESTDHANDLES - si.StdInput = GetStdHandle(-10); + si.StdInput = nullInput; si.StdOutput = outWrite; si.StdError = errWrite; StringBuilder commandLine = new StringBuilder("\"" + executable + "\" " + arguments); @@ -989,6 +999,7 @@ public static class HermesUpdateJob { if (outWrite != IntPtr.Zero) CloseHandle(outWrite); if (errRead != IntPtr.Zero) CloseHandle(errRead); if (errWrite != IntPtr.Zero) CloseHandle(errWrite); + if (nullInput != new IntPtr(-1)) CloseHandle(nullInput); } } @@ -1500,13 +1511,19 @@ try { if ($SelfTestWorkingDirectory) { $expectedRoot = [System.IO.Path]::GetFullPath($InstallRoot) $probeExe = Join-Path $PSHOME "powershell.exe" - $probe = Invoke-HermesStep $probeExe @("-NoProfile", "-Command", "[Environment]::CurrentDirectory") "cwd" - $observed = $probe.Output.Trim() + $probe = Invoke-HermesStep $probeExe @("-NoProfile", "-Command", "[Environment]::CurrentDirectory; [Console]::IsInputRedirected") "cwd" + $observed, $stdinRedirected = @($probe.Output.Trim() -split "`r?`n" | ForEach-Object { $_.Trim() }) if ($probe.Code -ne 0 -or -not [string]::Equals($observed, $expectedRoot, [StringComparison]::OrdinalIgnoreCase)) { $finalMsg = "WORKING-DIRECTORY SELF-TEST: FAIL expected=$expectedRoot observed=$observed code=$($probe.Code)" Write-Host $finalMsg exit 1 } + # A step that can read the hand-off console can block on a prompt nobody sees. + if ($stdinRedirected -ne "True") { + $finalMsg = "WORKING-DIRECTORY SELF-TEST: FAIL step stdin is an interactive console" + Write-Host $finalMsg + exit 1 + } $finalCode = 0 $finalMsg = "WORKING-DIRECTORY SELF-TEST: PASS $observed" Write-Host $finalMsg diff --git a/tests/scripts/desktop_update/test_desktop_update_windows_cwd.py b/tests/scripts/desktop_update/test_desktop_update_windows_cwd.py index 8ff38b8599..388f2ee491 100644 --- a/tests/scripts/desktop_update/test_desktop_update_windows_cwd.py +++ b/tests/scripts/desktop_update/test_desktop_update_windows_cwd.py @@ -63,6 +63,39 @@ def test_handoff_children_run_from_install_root(tmp_path: Path) -> None: assert "WORKING-DIRECTORY SELF-TEST: PASS" in result.stdout +def test_handoff_children_cannot_read_the_handoff_console(tmp_path: Path) -> None: + # The Desktop starts the hand-off with a visible console. A step that can + # read it asks its question into captured stdout and waits forever. + # CREATE_NEW_CONSOLE without redirection gives the hand-off a real console + # stdin, which is the production shape. The self-test fails when a step + # can read that console. + install_root = tmp_path / "checkout" + install_root.mkdir() + temp_dir = tmp_path / "temp" + temp_dir.mkdir() + output = tmp_path / "self-test.txt" + powershell = shutil.which("powershell.exe") + assert powershell, "Windows updater tests require Windows PowerShell." + env = os.environ.copy() + env["TEMP"] = str(temp_dir) + env["TMP"] = str(temp_dir) + command = ( + f"& '{WINDOWS_UPDATE_PS1}' -InstallRoot '{install_root}' " + f"-SelfTestWorkingDirectory -NoUi *> '{output}'; exit $LASTEXITCODE" + ) + result = subprocess.run( + [powershell, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command], + env=env, + creationflags=subprocess.CREATE_NEW_CONSOLE, + timeout=60, + check=False, + ) + + report = output.read_text(encoding="utf-8", errors="replace") if output.exists() else "" + assert result.returncode == 0, report + assert "WORKING-DIRECTORY SELF-TEST: PASS" in report + + def test_handoff_fails_closed_when_install_root_cannot_be_entered(tmp_path: Path) -> None: install_root = tmp_path / "missing" / "checkout" launch_cwd = tmp_path / "profile-home"