/logs | rg -n -C 8
- 'WindowsSshChildProcess.cs\\(14|error CS0102|Failed Suites|runtime-windows-no-input-launcher'`
+'WindowsSshChildProcess.cs\\(14|error CS0102|Failed Suites|runtime-windows-no-input-launcher'`
for each job.
- Native result: both architectures reach the purpose-named contract suite and the .NET Framework
compiler independently returns `CS0102`: the integer job-information-class constant and the
@@ -19729,6 +19729,84 @@ config/scripts/ssh-relay-runtime-workflow.test.mjs` passes 117 cases with two de
declared Windows skips. Syntax, `git diff --check`, and protected-resolver zero-diff pass. Commit/
push the correction and require both native architectures from the corrected exact head.
+### E-M6-WINDOWS-NOINPUT-LAUNCHER-LIVE-CI-RED-001 — private redirected pipes do not settle Win32-OpenSSH
+
+- Date/owner/head/run: 2026-07-16, Codex implementation owner; exact pushed head
+ `6a716de14884f9c08c1e08140f87849f137622e2`, artifact run `29551003702`, Windows x64 job
+ `87793308016` on `windows-2022`, and Windows ARM64 job `87793308044` on `windows-11-arm`.
+ GitHub records the jobs from 2026-07-17 02:53Z through 03:03Z.
+- Native launcher proof: both architectures compile with the legacy .NET Framework compiler and
+ pass exact argv quoting, pre-execution stdin EOF, binary stdout/stderr, exit-code propagation,
+ unrelated inherited-handle exclusion, job-owned cancellation, and bounded settlement. Each full
+ artifact contract step passes 101 files, 859 runnable cases, and 19 declared skips. Both jobs
+ build and verify the unchanged full runtime before starting the disconnected live fixture.
+- Live x64 result: `elapsedMs=8024.0679`, `success=false`, `timedOut=true`, `sentinel=false`,
+ `stdoutEnded=true`, `processExit=null`, `channelClosed=true`, `closeCode=null`, and
+ `stderrBytes=0`.
+- Live ARM64 result: `elapsedMs=8031.6544` with the same remaining lifecycle fields. Both official
+ server logs prove authentication and remote command-session closure. The launcher remains alive
+ until the bounded diagnostic terminates it, so this is not transfer or enablement evidence.
+- Teardown result: both exact owned-profile teardowns remain RED because `UsrClass.dat` is locked.
+ The workflow correctly stops before the unchanged product full-size Windows transfer and upload.
+ All non-Windows primary jobs and both Linux oldest-userland supplements pass in the same run.
+- Interpretation: Node's Windows pipe type and broad inherited-handle leakage are falsified as the
+ cause. Win32-OpenSSH issue #1330 separately reports the same hang when all standard handles are
+ redirected, including cases where `-n`/NUL and `-T` do not help, while a real console stdin
+ behaves differently. Do not add sentinel-based forced success or kill-after-output: trustworthy
+ remote exit-status framing would be a separate reviewed protocol contract.
+- Next bounded diagnostic: allocate a launcher-owned private console, hide it, open `CONIN$`, queue
+ console EOF input before `CreateProcessW`, and share that console with the SSH child. Retain
+ private stdout/stderr pipes, the exact three-handle inheritance list, suspended job assignment,
+ bounded pumps, exit propagation, and cancellation ownership. First prove the precise console EOF
+ semantics with a native purpose-named probe, then require the live x64 and ARM64 lifecycle and
+ exact teardown. Keep the executable runner-temp-only and disconnected from product packaging.
+
+### E-M6-WINDOWS-PRIVATE-CONSOLE-LOCAL-001 — private real-console candidate is locally contract-green
+
+- Date/owner/base/runner: 2026-07-16, Codex implementation owner; uncommitted capability atop exact
+ private-pipe RED head `6a716de14884f9c08c1e08140f87849f137622e2` on Apple arm64, macOS 26.2
+ build 25C56, Node 26.5.0, and pnpm 10.24.0.
+- Source audit: PowerShell/openssh-portable exact source `e98b11e5de035cbd0eecbddc95a87da42f14671f`
+ initializes Win32 standard handles as synchronous external I/O, classifies console input as
+ `FILE_TYPE_CHAR`, and routes it through the dedicated synchronous console `ReadFile` path in
+ `contrib/win32/win32compat/w32fd.c`, `fileio.c`, and `termio.c`. Orca's existing `-T` in
+ `system-ssh-args.ts` prevents real-console stdin from requesting a remote PTY.
+- Artifact boundary: the launcher captures its caller's stdout/stderr streams before detaching from
+ any inherited console, allocates a private console, hides any console window, opens inheritable
+ `CONIN$`, selects processed line input, flushes it, and queues Ctrl+Z key-down/up plus Enter key-
+ down/up before `CreateProcessW`. The child shares that private console; `CREATE_NO_WINDOW` is
+ deliberately absent because it would detach the child from the console. Private anonymous stdout/
+ stderr pipes, exact three-handle inheritance, suspended job assignment, bounded pumps, exit
+ propagation, and kill-on-job-close cancellation remain intact.
+- Native contract design: a purpose-named legacy-compiler probe rejects non-character stdin or
+ failed `GetConsoleMode`, selects cooked input, directly calls `ReadFile`, and requires zero bytes.
+ The existing suite separately requires console-qualified stdin plus exact argv, binary output,
+ exit propagation, unrelated inherited-handle exclusion, and bounded job-owned cancellation.
+ Five Windows-native cases are declared skips locally; neither the console semantics nor the live
+ OpenSSH result is claimed without x64 and ARM64 runners.
+- Focused command: `/usr/bin/time -l pnpm exec vitest run --config config/vitest.config.ts
+--maxWorkers=1 src/main/ssh/ssh-connection.test.ts src/main/ssh/ssh-system-fallback.test.ts
+src/main/ssh/ssh-system-no-input-windows-openssh.test.ts
+src/main/ssh/ssh-relay-runtime-windows-system-ssh-openssh-full-size.test.ts
+config/scripts/ssh-relay-runtime-windows-no-input-launcher.test.mjs
+config/scripts/ssh-relay-runtime-workflow.test.mjs` passes 119 runnable cases with seven declared
+ native/live skips in 3.27 seconds real; maximum RSS is 171,180,032 bytes and peak footprint is
+ 96,999,352 bytes.
+- Release contracts: `pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1
+--reporter=dot config/scripts/ssh-relay-runtime-*.test.mjs` passes 51 files and 285/285 runnable
+ cases with five declared Windows skips in 16.17 seconds. One prior quoted-glob invocation selected
+ no files and exited 1; it is operator error and not evidence.
+- Static gates: `/usr/bin/time -l pnpm typecheck` passes in 3.55 seconds real with maximum RSS
+ 1,257,357,312 bytes and peak footprint 97,507,040 bytes. `/usr/bin/time -l pnpm lint` passes in
+ 19.82 seconds real with the same 26 existing warnings, all 41 reliability gates, and the 355-entry
+ max-lines ratchet; maximum RSS is 2,041,757,696 bytes and peak footprint 97,179,360 bytes. Purpose-
+ file formatting, syntax, `git diff --check`, and protected-resolver zero-diff pass.
+- Residual boundary: commit/push this exact artifact-only package and require native x64/ARM64
+ legacy compilation plus all purpose-named contracts before interpreting the live result. A green
+ live diagnostic still would not authorize product packaging/caller, full-size transfer, fallback,
+ Beta, tuple enablement, publication, default behavior, or signing. A RED native probe or live
+ result must be recorded before selecting another mechanism such as ConPTY.
+
## Accepted Gaps
No product gap is accepted merely because it appears in this list. Each entry requires explicit
@@ -19787,10 +19865,10 @@ The project is not complete until every applicable item below is checked with ev
## Next Required Action
-Commit and push only the one-identifier compiler correction recorded by
-`E-M6-WINDOWS-NOINPUT-LAUNCHER-COMPILE-CI-RED-001`, then require exact-head Windows x64 and ARM64 legacy-
-compiler, argv/EOF/binary/exit/handle-exclusion/cancellation, live diagnostic, and exact profile/
-directory teardown evidence. Do not
+Commit and push only the private real-console stdin diagnostic recorded by
+`E-M6-WINDOWS-PRIVATE-CONSOLE-LOCAL-001`, then require exact-head Windows x64 and ARM64 legacy-
+compiler, console qualification/EOF, argv/binary/exit/handle-exclusion/cancellation, live
+diagnostic, and exact profile/directory teardown evidence. Do not
change the production Windows handle or check the broad Milestone 6 Windows system-SSH item until
both architectures select the same console-independent strategy. Do not add an
Electron/startup/product importer, per-target mode wiring, fallback, tuple
diff --git a/docs/reference/plans/2026-07-14-ssh-relay-github-release-plan.html b/docs/reference/plans/2026-07-14-ssh-relay-github-release-plan.html
index c6d07e314fd..ec3a4995eed 100644
--- a/docs/reference/plans/2026-07-14-ssh-relay-github-release-plan.html
+++ b/docs/reference/plans/2026-07-14-ssh-relay-github-release-plan.html
@@ -1657,15 +1657,21 @@
created zero-length regular-file descriptor that is already at EOF before
CreateProcess; its fixture cleanup passes, but the direct client
still times out after the server closes the command session. The next bounded
- artifact-only capability is a disconnected Windows launcher with private
+ artifact-only capability was a disconnected Windows launcher with private
anonymous stdin/stdout/stderr pipes, the stdin writer closed before
CreateProcessW, an exact inherited-handle list, hidden process
creation, bounded stdout/stderr pumps, exit propagation, and an owned job object
- that kills the SSH child when the launcher is cancelled. Build and live-test it
- on native x64/arm64 before packaging, signing, or adding a product caller. POSIX
- retains ignored stdin plus -n; fixture-owned Windows session
- processes settle before profile hives are boundedly unloaded and native profile
- deletion begins;
+ that kills the SSH child when the launcher is cancelled. Exact native x64/arm64
+ evidence proves that launcher compiles and passes all of those contracts, but
+ Win32-OpenSSH still remains alive after authenticated remote command-session
+ closure. This falsifies Node's pipe type and broad inherited-handle leakage. The
+ next artifact-only diagnostic uses a hidden launcher-owned private real console,
+ queues console EOF before child creation, and shares that console with the SSH
+ child while retaining private output pipes, exact handle inheritance, job
+ ownership, and bounded cancellation. Build and live-test it on native x64/arm64
+ before packaging, signing, or adding a product caller. POSIX retains ignored
+ stdin plus -n; fixture-owned Windows session processes settle
+ before profile hives are boundedly unloaded and native profile deletion begins;
MaxSessions=1; interrupted upload; stale lock
diff --git a/native/windows-ssh-no-input-launcher-test/WindowsConsoleEofProbe.cs b/native/windows-ssh-no-input-launcher-test/WindowsConsoleEofProbe.cs
new file mode 100644
index 00000000000..d2d1b06a03f
--- /dev/null
+++ b/native/windows-ssh-no-input-launcher-test/WindowsConsoleEofProbe.cs
@@ -0,0 +1,65 @@
+using System;
+using System.Runtime.InteropServices;
+
+internal static class WindowsConsoleEofProbe
+{
+ private const int StandardInputHandle = -10;
+ private const uint FileTypeChar = 0x0002;
+ private const uint EnableProcessedInput = 0x0001;
+ private const uint EnableLineInput = 0x0002;
+
+ private static int Main()
+ {
+ IntPtr input = GetStdHandle(StandardInputHandle);
+ uint mode;
+ if (input == IntPtr.Zero || input == new IntPtr(-1) || GetFileType(input) != FileTypeChar)
+ {
+ Console.Error.WriteLine("stdin is not a character-device handle");
+ return 2;
+ }
+ if (!GetConsoleMode(input, out mode))
+ {
+ Console.Error.WriteLine("stdin is not a console input handle");
+ return 3;
+ }
+ if (!SetConsoleMode(input, mode | EnableProcessedInput | EnableLineInput))
+ {
+ Console.Error.WriteLine("unable to select cooked console input");
+ return 4;
+ }
+
+ byte[] buffer = new byte[1];
+ uint bytesRead;
+ if (!ReadFile(input, buffer, (uint)buffer.Length, out bytesRead, IntPtr.Zero))
+ {
+ Console.Error.WriteLine("console ReadFile failed: {0}", Marshal.GetLastWin32Error());
+ return 5;
+ }
+ Console.Out.Write("ORCA-PRIVATE-CONSOLE-EOF bytes={0}", bytesRead);
+ return bytesRead == 0 ? 0 : 6;
+ }
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern IntPtr GetStdHandle(int standardHandle);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern uint GetFileType(IntPtr file);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool GetConsoleMode(IntPtr console, out uint mode);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool SetConsoleMode(IntPtr console, uint mode);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool ReadFile(
+ IntPtr file,
+ [Out] byte[] buffer,
+ uint bytesToRead,
+ out uint bytesRead,
+ IntPtr overlapped
+ );
+}
diff --git a/native/windows-ssh-no-input-launcher/WindowsPrivateConsoleInput.cs b/native/windows-ssh-no-input-launcher/WindowsPrivateConsoleInput.cs
new file mode 100644
index 00000000000..b1da1beff1b
--- /dev/null
+++ b/native/windows-ssh-no-input-launcher/WindowsPrivateConsoleInput.cs
@@ -0,0 +1,251 @@
+using System;
+using System.ComponentModel;
+using System.Runtime.InteropServices;
+
+internal sealed class WindowsPrivateConsoleInput : IDisposable
+{
+ private const uint EnableProcessedInput = 0x0001;
+ private const uint EnableLineInput = 0x0002;
+ private const uint GenericRead = 0x80000000;
+ private const uint GenericWrite = 0x40000000;
+ private const uint FileShareRead = 0x00000001;
+ private const uint FileShareWrite = 0x00000002;
+ private const uint OpenExisting = 3;
+ private const uint HandleFlagInherit = 0x00000001;
+ private const short KeyEvent = 0x0001;
+ private const ushort VirtualKeyZ = 0x5a;
+ private const ushort VirtualKeyReturn = 0x0d;
+ private const uint MapVirtualKeyToScanCode = 0;
+ private const uint LeftCtrlPressed = 0x0008;
+ private const int SwHide = 0;
+ private const int ErrorInvalidHandle = 6;
+
+ private IntPtr inputHandle;
+ private bool ownsConsole;
+
+ private WindowsPrivateConsoleInput()
+ {
+ }
+
+ internal IntPtr InputHandle { get { return inputHandle; } }
+
+ internal static WindowsPrivateConsoleInput Create()
+ {
+ WindowsPrivateConsoleInput console = new WindowsPrivateConsoleInput();
+ try
+ {
+ console.DetachFromInheritedConsole();
+ if (!AllocConsole())
+ {
+ throw LastWin32("AllocConsole failed.");
+ }
+ console.ownsConsole = true;
+ HideConsoleWindow();
+
+ console.inputHandle = CreateFile(
+ "CONIN$",
+ GenericRead | GenericWrite,
+ FileShareRead | FileShareWrite,
+ IntPtr.Zero,
+ OpenExisting,
+ 0,
+ IntPtr.Zero
+ );
+ if (console.inputHandle == new IntPtr(-1))
+ {
+ console.inputHandle = IntPtr.Zero;
+ throw LastWin32("Unable to open the private console input buffer.");
+ }
+ if (!SetConsoleMode(console.inputHandle, EnableProcessedInput | EnableLineInput))
+ {
+ throw LastWin32("SetConsoleMode failed for private console input.");
+ }
+ if (!SetHandleInformation(
+ console.inputHandle,
+ HandleFlagInherit,
+ HandleFlagInherit
+ ))
+ {
+ throw LastWin32("Unable to make private console input inheritable.");
+ }
+ if (!FlushConsoleInputBuffer(console.inputHandle))
+ {
+ throw LastWin32("FlushConsoleInputBuffer failed.");
+ }
+ console.QueueEndOfInput();
+ return console;
+ }
+ catch
+ {
+ console.Dispose();
+ throw;
+ }
+ }
+
+ public void Dispose()
+ {
+ if (inputHandle != IntPtr.Zero)
+ {
+ CloseHandle(inputHandle);
+ inputHandle = IntPtr.Zero;
+ }
+ if (ownsConsole)
+ {
+ FreeConsole();
+ ownsConsole = false;
+ }
+ }
+
+ private void QueueEndOfInput()
+ {
+ // Why: Win32 console EOF is Ctrl+Z followed by Enter in cooked input mode; queue it before
+ // child creation so the SSH input worker cannot race launcher startup.
+ InputRecord[] records = new InputRecord[]
+ {
+ CreateKeyRecord(true, VirtualKeyZ, '\u001a', LeftCtrlPressed),
+ CreateKeyRecord(false, VirtualKeyZ, '\u001a', LeftCtrlPressed),
+ CreateKeyRecord(true, VirtualKeyReturn, '\r', 0),
+ CreateKeyRecord(false, VirtualKeyReturn, '\r', 0)
+ };
+ uint written;
+ if (!WriteConsoleInput(inputHandle, records, (uint)records.Length, out written))
+ {
+ throw LastWin32("WriteConsoleInputW failed.");
+ }
+ if (written != (uint)records.Length)
+ {
+ throw new Win32Exception("WriteConsoleInputW queued an incomplete EOF sequence.");
+ }
+ }
+
+ private void DetachFromInheritedConsole()
+ {
+ if (!FreeConsole())
+ {
+ int error = Marshal.GetLastWin32Error();
+ if (error != ErrorInvalidHandle)
+ {
+ throw new Win32Exception(error, "FreeConsole failed.");
+ }
+ }
+ }
+
+ private static void HideConsoleWindow()
+ {
+ IntPtr window = GetConsoleWindow();
+ if (window == IntPtr.Zero)
+ {
+ return;
+ }
+ ShowWindow(window, SwHide);
+ if (IsWindowVisible(window))
+ {
+ throw new Win32Exception("The private SSH console window remained visible.");
+ }
+ }
+
+ private static InputRecord CreateKeyRecord(
+ bool keyDown,
+ ushort virtualKey,
+ char character,
+ uint controlState
+ )
+ {
+ InputRecord record = new InputRecord();
+ record.EventType = KeyEvent;
+ record.KeyEvent = new KeyEventRecord();
+ record.KeyEvent.KeyDown = keyDown;
+ record.KeyEvent.RepeatCount = 1;
+ record.KeyEvent.VirtualKeyCode = virtualKey;
+ record.KeyEvent.VirtualScanCode = (ushort)MapVirtualKey(virtualKey, MapVirtualKeyToScanCode);
+ record.KeyEvent.UnicodeChar = character;
+ record.KeyEvent.ControlKeyState = controlState;
+ return record;
+ }
+
+ private static Win32Exception LastWin32(string message)
+ {
+ return new Win32Exception(Marshal.GetLastWin32Error(), message);
+ }
+
+ [StructLayout(LayoutKind.Explicit, CharSet = CharSet.Unicode)]
+ private struct InputRecord
+ {
+ [FieldOffset(0)] internal short EventType;
+ [FieldOffset(4)] internal KeyEventRecord KeyEvent;
+ }
+
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+ private struct KeyEventRecord
+ {
+ [MarshalAs(UnmanagedType.Bool)] internal bool KeyDown;
+ internal ushort RepeatCount;
+ internal ushort VirtualKeyCode;
+ internal ushort VirtualScanCode;
+ internal char UnicodeChar;
+ internal uint ControlKeyState;
+ }
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool AllocConsole();
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool FreeConsole();
+
+ [DllImport("kernel32.dll")]
+ private static extern IntPtr GetConsoleWindow();
+
+ [DllImport("user32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool ShowWindow(IntPtr window, int command);
+
+ [DllImport("user32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool IsWindowVisible(IntPtr window);
+
+ [DllImport(
+ "kernel32.dll",
+ EntryPoint = "CreateFileW",
+ CharSet = CharSet.Unicode,
+ SetLastError = true
+ )]
+ private static extern IntPtr CreateFile(
+ string fileName,
+ uint desiredAccess,
+ uint shareMode,
+ IntPtr securityAttributes,
+ uint creationDisposition,
+ uint flagsAndAttributes,
+ IntPtr templateFile
+ );
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool SetConsoleMode(IntPtr consoleInput, uint mode);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool FlushConsoleInputBuffer(IntPtr consoleInput);
+
+ [DllImport("kernel32.dll", EntryPoint = "WriteConsoleInputW", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool WriteConsoleInput(
+ IntPtr consoleInput,
+ [In] InputRecord[] records,
+ uint recordCount,
+ out uint recordsWritten
+ );
+
+ [DllImport("user32.dll")]
+ private static extern uint MapVirtualKey(uint code, uint mapType);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool SetHandleInformation(IntPtr handle, uint mask, uint flags);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool CloseHandle(IntPtr handle);
+}
diff --git a/native/windows-ssh-no-input-launcher/WindowsAnonymousPipeSet.cs b/native/windows-ssh-no-input-launcher/WindowsSshChildIo.cs
similarity index 86%
rename from native/windows-ssh-no-input-launcher/WindowsAnonymousPipeSet.cs
rename to native/windows-ssh-no-input-launcher/WindowsSshChildIo.cs
index 0a6efc8bd1f..a7d59c19254 100644
--- a/native/windows-ssh-no-input-launcher/WindowsAnonymousPipeSet.cs
+++ b/native/windows-ssh-no-input-launcher/WindowsSshChildIo.cs
@@ -6,12 +6,14 @@ using System.Runtime.InteropServices;
using System.Threading;
using Microsoft.Win32.SafeHandles;
-internal sealed class WindowsAnonymousPipeSet : IDisposable
+internal sealed class WindowsSshChildIo : IDisposable
{
private const uint HandleFlagInherit = 0x00000001;
private const int PumpCompletionTimeoutMilliseconds = 5000;
- private IntPtr stdinRead;
+ private readonly IntPtr stdinRead;
+ private readonly Stream stdoutDestination;
+ private readonly Stream stderrDestination;
private IntPtr stdoutWrite;
private IntPtr stderrWrite;
private SafeFileHandle stdoutRead;
@@ -21,8 +23,15 @@ internal sealed class WindowsAnonymousPipeSet : IDisposable
private Exception pumpFailure;
private readonly ManualResetEvent pumpFailureSignal = new ManualResetEvent(false);
- private WindowsAnonymousPipeSet()
+ private WindowsSshChildIo(
+ IntPtr stdinRead,
+ Stream stdoutDestination,
+ Stream stderrDestination
+ )
{
+ this.stdinRead = stdinRead;
+ this.stdoutDestination = stdoutDestination;
+ this.stderrDestination = stderrDestination;
}
internal IntPtr StdinRead { get { return stdinRead; } }
@@ -33,21 +42,23 @@ internal sealed class WindowsAnonymousPipeSet : IDisposable
get { return pumpFailureSignal.SafeWaitHandle.DangerousGetHandle(); }
}
- internal static WindowsAnonymousPipeSet Create()
+ internal static WindowsSshChildIo Create(
+ IntPtr stdinRead,
+ Stream stdoutDestination,
+ Stream stderrDestination
+ )
{
- WindowsAnonymousPipeSet pipes = new WindowsAnonymousPipeSet();
+ WindowsSshChildIo pipes = new WindowsSshChildIo(
+ stdinRead,
+ stdoutDestination,
+ stderrDestination
+ );
try
{
SecurityAttributes security = new SecurityAttributes();
security.Length = Marshal.SizeOf(typeof(SecurityAttributes));
security.InheritHandle = true;
- IntPtr stdinWrite;
- CreateCheckedPipe(out pipes.stdinRead, out stdinWrite, security);
- SetNonInheritable(stdinWrite);
- // Why: Win32-OpenSSH must see EOF before CreateProcessW, not after startup.
- CloseHandleChecked(stdinWrite);
-
IntPtr stdoutReadHandle;
CreateCheckedPipe(out stdoutReadHandle, out pipes.stdoutWrite, security);
SetNonInheritable(stdoutReadHandle);
@@ -68,7 +79,6 @@ internal sealed class WindowsAnonymousPipeSet : IDisposable
internal void CloseChildEndsInParent()
{
- CloseOwnedHandle(ref stdinRead);
CloseOwnedHandle(ref stdoutWrite);
CloseOwnedHandle(ref stderrWrite);
}
@@ -77,11 +87,11 @@ internal sealed class WindowsAnonymousPipeSet : IDisposable
{
SafeFileHandle stdoutHandle = stdoutRead;
stdoutRead = null;
- stdoutPump = StartPump(stdoutHandle, Console.OpenStandardOutput(), "stdout");
+ stdoutPump = StartPump(stdoutHandle, stdoutDestination, "stdout");
SafeFileHandle stderrHandle = stderrRead;
stderrRead = null;
- stderrPump = StartPump(stderrHandle, Console.OpenStandardError(), "stderr");
+ stderrPump = StartPump(stderrHandle, stderrDestination, "stderr");
}
internal void CompletePumps()
@@ -208,14 +218,6 @@ internal sealed class WindowsAnonymousPipeSet : IDisposable
}
}
- private static void CloseHandleChecked(IntPtr handle)
- {
- if (!CloseHandle(handle))
- {
- throw new Win32Exception(Marshal.GetLastWin32Error(), "CloseHandle failed.");
- }
- }
-
private static void CloseOwnedHandle(ref IntPtr handle)
{
if (handle != IntPtr.Zero)
diff --git a/native/windows-ssh-no-input-launcher/WindowsSshChildProcess.cs b/native/windows-ssh-no-input-launcher/WindowsSshChildProcess.cs
index c7327e593b1..e24c83a0b2b 100644
--- a/native/windows-ssh-no-input-launcher/WindowsSshChildProcess.cs
+++ b/native/windows-ssh-no-input-launcher/WindowsSshChildProcess.cs
@@ -8,7 +8,6 @@ internal static class WindowsSshChildProcess
{
private const uint CreateSuspended = 0x00000004;
private const uint ExtendedStartupInfoPresent = 0x00080000;
- private const uint CreateNoWindow = 0x08000000;
private const uint StartfUseStdHandles = 0x00000100;
private const uint JobObjectLimitKillOnJobClose = 0x00002000;
private const int JobObjectExtendedLimitInformationClass = 9;
@@ -19,7 +18,14 @@ internal static class WindowsSshChildProcess
internal static int Run(string executablePath, string[] args)
{
- using (WindowsAnonymousPipeSet pipes = WindowsAnonymousPipeSet.Create())
+ Stream launcherStdout = Console.OpenStandardOutput();
+ Stream launcherStderr = Console.OpenStandardError();
+ using (WindowsPrivateConsoleInput console = WindowsPrivateConsoleInput.Create())
+ using (WindowsSshChildIo pipes = WindowsSshChildIo.Create(
+ console.InputHandle,
+ launcherStdout,
+ launcherStderr
+ ))
{
IntPtr job = IntPtr.Zero;
IntPtr attributeList = IntPtr.Zero;
@@ -32,7 +38,9 @@ internal static class WindowsSshChildProcess
job = CreateKillOnCloseJob();
StartupInfoEx startup = CreateStartupInfo(pipes, out attributeList, out inheritedHandles);
StringBuilder commandLine = WindowsCommandLine.Build(executablePath, args);
- uint flags = CreateSuspended | ExtendedStartupInfoPresent | CreateNoWindow;
+ // Why: the SSH child must share the hidden private console for CONIN$ to remain a
+ // real console handle; CREATE_NO_WINDOW would detach it from that console.
+ uint flags = CreateSuspended | ExtendedStartupInfoPresent;
if (!CreateProcess(
executablePath,
commandLine,
@@ -125,7 +133,7 @@ internal static class WindowsSshChildProcess
}
private static StartupInfoEx CreateStartupInfo(
- WindowsAnonymousPipeSet pipes,
+ WindowsSshChildIo pipes,
out IntPtr attributeList,
out IntPtr inheritedHandles
)
diff --git a/src/main/ssh/ssh-system-no-input-windows-openssh.test.ts b/src/main/ssh/ssh-system-no-input-windows-openssh.test.ts
index b11cb29a9f6..f7f8730ef7e 100644
--- a/src/main/ssh/ssh-system-no-input-windows-openssh.test.ts
+++ b/src/main/ssh/ssh-system-no-input-windows-openssh.test.ts
@@ -7,7 +7,7 @@ import type { SshTarget } from '../../shared/ssh-types'
import { buildSshArgs } from './system-ssh-args'
import { findSystemSsh } from './system-ssh-binary'
-type DiagnosticMode = 'private-pipe-launcher-no-n'
+type DiagnosticMode = 'private-console-launcher-no-n'
type DiagnosticResult = {
mode: DiagnosticMode
@@ -48,10 +48,10 @@ async function runDiagnostic(): Promise {
if (!sshPath) {
throw new Error('Native Windows OpenSSH client is unavailable')
}
- const mode: DiagnosticMode = 'private-pipe-launcher-no-n'
+ const mode: DiagnosticMode = 'private-console-launcher-no-n'
const startedAt = performance.now()
- // Why: the disconnected launcher creates private child pipes so Win32-OpenSSH receives EOF
- // before CreateProcessW without depending on Node's Windows named-pipe behavior.
+ // Why: redirected stdio hangs in Win32-OpenSSH even with private pipes; this diagnostic gives
+ // only stdin a hidden real-console boundary with EOF queued before CreateProcessW.
const child = spawn(
launcherPath as string,
[sshPath, ...buildSshArgs(createTarget()), 'echo ORCA-SYSTEM-SSH-OK'],
@@ -138,16 +138,16 @@ async function runDiagnostic(): Promise {
describe.skipIf(!hasLiveInput)('Windows OpenSSH no-input child-handle diagnostic', () => {
it(
- 'qualifies private-pipe stdin EOF without the Windows null-input option',
+ 'qualifies private-console stdin EOF without the Windows null-input option',
{ timeout: 15_000 },
async () => {
expect(process.platform).toBe('win32')
- const privatePipeLauncherNoN = await runDiagnostic()
+ const privateConsoleLauncherNoN = await runDiagnostic()
console.log(
- `ssh_windows_no_input_handle_diagnostic=${JSON.stringify({ privatePipeLauncherNoN })}`
+ `ssh_windows_no_input_handle_diagnostic=${JSON.stringify({ privateConsoleLauncherNoN })}`
)
- expect(privatePipeLauncherNoN.success).toBe(true)
+ expect(privateConsoleLauncherNoN.success).toBe(true)
}
)
})
|