test(ssh): add private-console Windows diagnostic

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-07-16 23:19:03 -04:00
co-authored by Orca
parent 6a716de148
commit 5841b12bc9
10 changed files with 550 additions and 72 deletions
@@ -12,8 +12,9 @@ const repoRoot = resolve(import.meta.dirname, '../..')
const sourceRoot = join(repoRoot, 'native', 'windows-ssh-no-input-launcher')
const sourcePaths = [
'OrcaSshNoInputLauncher.cs',
'WindowsAnonymousPipeSet.cs',
'WindowsCommandLine.cs',
'WindowsPrivateConsoleInput.cs',
'WindowsSshChildIo.cs',
'WindowsSshChildProcess.cs'
].map((name) => join(sourceRoot, name))
const outputPath = readArg('--output') ?? join(sourceRoot, '.build', 'orca-ssh-no-input.exe')
@@ -19,6 +19,7 @@ let fixtureRoot
let launcherPath
let childFixturePath
let handleProbePath
let consoleEofProbePath
beforeAll(() => {
if (process.platform !== 'win32') {
@@ -28,6 +29,7 @@ beforeAll(() => {
launcherPath = join(fixtureRoot, 'orca-ssh-no-input.exe')
childFixturePath = join(fixtureRoot, 'child-fixture.cjs')
handleProbePath = join(fixtureRoot, 'windows-launcher-handle-probe.exe')
consoleEofProbePath = join(fixtureRoot, 'windows-console-eof-probe.exe')
writeFileSync(childFixturePath, childFixtureSource, 'utf8')
const build = spawnSync(process.execPath, [buildScript, '--output', launcherPath], {
cwd: projectRoot,
@@ -57,6 +59,27 @@ beforeAll(() => {
{ cwd: projectRoot, encoding: 'utf8' }
)
expect(probeBuild.status, `${probeBuild.stdout}\n${probeBuild.stderr}`).toBe(0)
const eofProbeSource = join(
projectRoot,
'native',
'windows-ssh-no-input-launcher-test',
'WindowsConsoleEofProbe.cs'
)
const eofProbeBuild = spawnSync(
compilerPath,
[
'/nologo',
'/target:exe',
'/platform:anycpu',
'/optimize+',
'/warnaserror+',
`/out:${consoleEofProbePath}`,
eofProbeSource
],
{ cwd: projectRoot, encoding: 'utf8' }
)
expect(eofProbeBuild.status, `${eofProbeBuild.stdout}\n${eofProbeBuild.stderr}`).toBe(0)
}, 20_000)
afterAll(() => {
@@ -68,16 +91,28 @@ afterAll(() => {
describe('Windows SSH no-input launcher', () => {
it('keeps the artifact source scoped to the reviewed Win32 boundary', () => {
const processSource = readFileSync(join(sourceRoot, 'WindowsSshChildProcess.cs'), 'utf8')
const pipeSource = readFileSync(join(sourceRoot, 'WindowsAnonymousPipeSet.cs'), 'utf8')
const consoleSource = readFileSync(join(sourceRoot, 'WindowsPrivateConsoleInput.cs'), 'utf8')
const pipeSource = readFileSync(join(sourceRoot, 'WindowsSshChildIo.cs'), 'utf8')
expect(processSource).toContain('ProcThreadAttributeHandleList')
expect(processSource).toContain('IntPtr.Size * 3')
expect(processSource).toContain('CreateSuspended')
expect(processSource).toContain('CreateNoWindow')
expect(processSource).not.toContain('CreateNoWindow')
expect(processSource).toContain('JobObjectLimitKillOnJobClose')
expect(processSource).toContain('WaitForMultipleObjects')
expect(processSource).toContain('if (processStarted && !assignedToJob)')
expect(pipeSource).toContain('CloseHandleChecked(stdinWrite)')
expect(consoleSource).toContain('AllocConsole()')
expect(consoleSource).toContain('"CONIN$"')
expect(consoleSource).toContain('ShowWindow(window, SwHide)')
expect(consoleSource).toContain('WriteConsoleInput')
expect(consoleSource).toContain('console.QueueEndOfInput()')
expect(processSource).toContain('WindowsPrivateConsoleInput.Create()')
expect(consoleSource.indexOf('console.QueueEndOfInput()')).toBeLessThan(
consoleSource.indexOf('return console;')
)
expect(processSource.indexOf('WindowsPrivateConsoleInput.Create()')).toBeLessThan(
processSource.indexOf('CreateProcess(')
)
expect(pipeSource).toContain('PumpCompletionTimeoutMilliseconds')
expect(pipeSource).toContain('pumpFailureSignal.Set()')
})
@@ -95,14 +130,29 @@ describe('Windows SSH no-input launcher', () => {
}
)
itWindows('preserves exact arguments and provides child stdin EOF before execution', () => {
itWindows('preserves exact arguments and qualifies private-console stdin', () => {
const args = ['', 'space value', 'quote"value', 'trailing slash\\', 'line one\nline two', '雪']
const result = runLauncher('arguments-and-stdin', args, {
input: Buffer.from('this input must not reach the SSH child', 'utf8')
})
expect(result.status, result.stderr.toString('utf8')).toBe(0)
expect(JSON.parse(result.stdout.toString('utf8'))).toEqual({ args, stdinBytes: 0 })
expect(JSON.parse(result.stdout.toString('utf8'))).toEqual({
args,
stdinIsTTY: true
})
})
itWindows('queues cooked private-console EOF before child execution', () => {
const result = spawnSync(launcherPath, [consoleEofProbePath], {
cwd: fixtureRoot,
encoding: 'utf8',
timeout: 10_000,
windowsHide: true
})
expect(result.status, result.stderr).toBe(0)
expect(result.stdout).toBe('ORCA-PRIVATE-CONSOLE-EOF bytes=0')
})
itWindows('preserves binary stdout and stderr and propagates the child exit code', () => {
@@ -219,12 +269,10 @@ async function waitForProcessExit(pid, timeoutMs) {
const childFixtureSource = String.raw`const mode = process.argv[2]
if (mode === 'arguments-and-stdin') {
let stdinBytes = 0
process.stdin.on('data', (chunk) => { stdinBytes += chunk.length })
process.stdin.on('end', () => {
process.stdout.write(JSON.stringify({ args: process.argv.slice(3), stdinBytes }))
})
process.stdin.resume()
process.stdout.write(JSON.stringify({
args: process.argv.slice(3),
stdinIsTTY: process.stdin.isTTY === true
}))
} else if (mode === 'binary-output') {
process.stdout.write(Buffer.from([0x00, 0xff, 0x10, 0x0a, 0x80]))
process.stderr.write(Buffer.from([0xfe, 0x00, 0x7f, 0x0d, 0x0a]))
@@ -1,6 +1,6 @@
# SSH Relay Runtime Distribution — Short Implementation Checklist
Last updated: 2026-07-15
Last updated: 2026-07-16
Use this file to track the project. The
[detailed evidence ledger](./2026-07-14-ssh-relay-github-release-implementation-checklist.md)
@@ -9,8 +9,8 @@ keeps commands, hashes, runner identities, timings, and failure details.
A checked box means the work has evidence in the detailed ledger. Design approval alone does not
complete a box.
Active checkpoint: **Milestone 6 / Work Package 5 bounded runtime transfer — disconnected Windows
launcher compiler correction, 2026-07-15, Codex implementation owner.**
Active checkpoint: **Milestone 6 / Work Package 5 bounded runtime transfer — disconnected private-
console Windows diagnostic, 2026-07-16, Codex implementation owner.**
`E-M6-WINDOWS-SYSTEM-SSH-TREE-LIVE-PORTABLE-FIXTURE-LOCAL-001` records the portable-fixture
correction locally green: the no-Orca-publication workflow oracle permits the one exact pinned
Microsoft fixture release URL while Orca release URLs, `gh release`, and `contents: write` remain
@@ -168,7 +168,26 @@ ARM64 live evidence next. Do not package or call it from production; SignPath re
`29550660572`, and independent x64/ARM64 jobs `87792250731`/`87792250710`: both legacy compilers
reject one constant/structure identifier collision before emitting an executable. The isolated
correction renames only the integer information-class constant; focused 12/12 and release contracts
285/285 pass locally. Commit/push and rerun both architectures next.
285/285 pass locally. Corrected exact head `6a716de14`, run `29551003702`, and x64/ARM64 jobs
`87793308016`/`87793308044` then pass legacy compilation and every native launcher contract plus the
complete 101-file/859-case artifact contract step, but the live diagnostic still times out after
8024.0679/8031.6544 milliseconds. Both observe ended stdout, a closed SSH channel, no sentinel or
process exit, and zero stderr; both exact profile teardowns remain RED on locked `UsrClass.dat`.
`E-M6-WINDOWS-NOINPUT-LAUNCHER-LIVE-CI-RED-001` therefore falsifies Node's pipe type and broad
inherited-handle leakage. Next test only a hidden launcher-owned real-console stdin with console EOF
queued before child creation while retaining private output pipes, exact handle inheritance, job
ownership, and bounded cancellation. Keep it runner-temp-only and disconnected from product;
SignPath remains deferred.
`E-M6-WINDOWS-PRIVATE-CONSOLE-LOCAL-001` records the replacement candidate locally green. It
captures launcher output before detaching, allocates and hides a private console, queues Ctrl+Z plus
Enter into `CONIN$` before child creation, and shares that console with the suspended/job-owned SSH
child while retaining private output pipes and the exact three-handle list. A dedicated native probe
requires character-device stdin, successful `GetConsoleMode`, and a zero-byte cooked `ReadFile`.
Focused 119/119 with seven native skips, all 285 release contracts with five native skips, typecheck,
full lint/reliability/max-lines, formatting, diff, and protected-resolver isolation pass. Commit/push
this artifact-only package and require fresh x64/ARM64 compiler, console/EOF, launcher, live SSH, and
exact teardown proof; no product, Beta, fallback, tuple, publication, default, or SignPath work is
included.
`E-M6-WINDOWS-SYSTEM-SSH-TREE-LIVE-AUDIT-001` fixes the loopback-only official Microsoft
server, fixture-owned non-admin account/ACL, exact host-key trust, Windows PowerShell 5.1,
serial/default and four-channel metrics, cancellation/collision/cleanup, and deterministic teardown
@@ -7,9 +7,9 @@ This file is the detailed evidence ledger. Use the tracker above for project sta
work; keep exact commands, runner identities, hashes, metrics, and residual gaps here.
Date created: 2026-07-14<br>
Last updated: 2026-07-15<br>
Current phase: Milestone 6 / Work Package 5 bounded runtime transfer — **In progress — 2026-07-15, Codex implementation owner: disconnected Windows launcher compiler correction**. `E-M6-WINDOWS-NOINPUT-LAUNCHER-COMPILE-CI-RED-001` records exact head `1f220a7135b83ef4c5f035ab729c114e828bf5b3`, run `29550660572`, and the same `CS0102` identifier collision from Windows x64/ARM64 jobs `87792250731`/`87792250710`. Rename only the integer job-information-class constant, re-prove local contracts, and require fresh native x64/ARM64 evidence. Keep every capability disconnected from every production/default SSH path. Real Apple/SignPath rehearsals remain deferred.<br>
Session checkpoint: **LAUNCHER LOCAL CONTRACTS GREEN / X64 AND ARM64 LEGACY COMPILERS RED / ONE-IDENTIFIER CORRECTION ACTIVE — 2026-07-15, Codex implementation owner** — local focused 119/119, release contracts 285/285, typecheck, full lint/reliability/max-lines, formatting, diff, and resolver isolation pass. Both native jobs independently reject the constant/structure name collision before producing an executable or reaching live SSH; the runner-temp build, live fixture, full-size test, upload, production caller, legacy behavior, Beta, tuple publication, and SignPath remain untouched. Rename only `JobObjectExtendedLimitInformation` constant to `JobObjectExtendedLimitInformationClass`, commit/push, and rerun both native architectures.<br>
Last updated: 2026-07-16<br>
Current phase: Milestone 6 / Work Package 5 bounded runtime transfer — **In progress — 2026-07-16, Codex implementation owner: disconnected private-console Windows diagnostic native proof**. `E-M6-WINDOWS-PRIVATE-CONSOLE-LOCAL-001` records the unpackaged, unsigned, runner-temp diagnostic locally green. Commit/push only this artifact-only package and require fresh Windows x64/ARM64 legacy-compiler, real-console qualification, pre-execution cooked EOF, argv/binary/exit/handle-exclusion/cancellation, live lifecycle, and exact teardown proof. Keep every capability disconnected from every production/default SSH path. Real Apple/SignPath rehearsals remain deferred.<br>
Session checkpoint: **PRIVATE CONSOLE CONTRACTS LOCALLY GREEN / X64 AND ARM64 NATIVE PROOF REQUIRED — 2026-07-16, Codex implementation owner** — the launcher captures its caller output streams, detaches from any inherited console, allocates and hides a private console, queues Ctrl+Z plus Enter into `CONIN$` before child creation, and shares that console with the suspended/job-owned child while retaining private stdout/stderr pipes and the exact three-handle list. A dedicated native probe requires character-device stdin, successful `GetConsoleMode`, and zero-byte cooked `ReadFile`. Local focused 119/119 with seven native skips, release contracts 285/285 with five native skips, typecheck, full lint/reliability/max-lines, formatting, diff, and resolver isolation pass. No product caller, full-size transfer claim, upload, default/fallback, Beta, tuple publication, or signing behavior changed.<br>
Primary design: [SSH relay GitHub Release plan](./2026-07-14-ssh-relay-github-release-plan.html)<br>
Motivating issues: [#8450](https://github.com/stablyai/orca/issues/8450), [#1693](https://github.com/stablyai/orca/issues/1693)
@@ -19627,8 +19627,8 @@ config/scripts/ssh-relay-runtime-workflow.test.mjs` passes 117 cases with two de
`eee686d7f6203c88f00d607711d3816aaf444c61`, artifact run `29549252407`, Windows x64 job
`87788106340` on `windows-2022`, and Windows ARM64 job `87788106351` on `windows-11-arm`.
- Exact log command: `gh api repos/stablyai/orca/actions/jobs/<job-id>/logs | rg -n -C 15
'ssh_windows_no_input_handle_diagnostic|Windows no-input handle diagnostic failed|profile
teardown:|UsrClass.dat|regular-file-eof-no-n|stdinFixtureRemoved|sentinel|stderrBytes'` for each
'ssh_windows_no_input_handle_diagnostic|Windows no-input handle diagnostic failed|profile
teardown:|UsrClass.dat|regular-file-eof-no-n|stdinFixtureRemoved|sentinel|stderrBytes'` for each
job.
- Native result: x64 returns `success=false`, `timedOut=true`, `sentinel=false`,
`stdoutEnded=true`, `processExit=null`, `channelClosed=true`, `closeCode=null`, `stderrBytes=0`,
@@ -19678,15 +19678,15 @@ config/scripts/ssh-relay-runtime-workflow.test.mjs` passes 117 cases with two de
references in that step. Runtime archives, release evidence, product imports, production system-
SSH arguments/handles, legacy fallback/default, Beta, tuple publication, and signing are unchanged.
- 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 cases with six declared native
--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 cases with six declared native
skips in 4.73 seconds real; maximum RSS is 167,936,000 bytes and peak footprint is 98,735,864
bytes.
- Release contracts: `/usr/bin/time -l pnpm exec vitest run --config config/vitest.config.ts
--maxWorkers=1 --reporter=dot config/scripts/ssh-relay-runtime-*.test.mjs` passes 51 files and
--maxWorkers=1 --reporter=dot config/scripts/ssh-relay-runtime-*.test.mjs` passes 51 files and
285/285 runnable cases with four declared Windows skips in 25.07 seconds real; maximum RSS is
195,428,352 bytes and peak footprint is 97,638,160 bytes. One preceding quoted-glob invocation
selected no files and exited 1; it is recorded as operator error and is not evidence.
@@ -19709,7 +19709,7 @@ config/scripts/ssh-relay-runtime-workflow.test.mjs` passes 117 cases with two de
`1f220a7135b83ef4c5f035ab729c114e828bf5b3`, artifact run `29550660572`, Windows x64 job
`87792250731` on `windows-2022`, and Windows ARM64 job `87792250710` on `windows-11-arm`.
- Exact log command: `gh api repos/stablyai/orca/actions/jobs/<job-id>/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
@@ -1657,15 +1657,21 @@
created zero-length regular-file descriptor that is already at EOF before
<code>CreateProcess</code>; 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
<code>CreateProcessW</code>, 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 <code>-n</code>; 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 <code>-n</code>; fixture-owned Windows session processes settle
before profile hives are boundedly unloaded and native profile deletion begins;
<code>MaxSessions=1</code>; interrupted upload; stale lock
</td>
<td>
@@ -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
);
}
@@ -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);
}
@@ -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)
@@ -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
)
@@ -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<DiagnosticResult> {
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<DiagnosticResult> {
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)
}
)
})