diff --git a/config/scripts/ssh-relay-runtime-windows-no-input-launcher.test.mjs b/config/scripts/ssh-relay-runtime-windows-no-input-launcher.test.mjs index 7eabf2500f4..521d7bb7696 100644 --- a/config/scripts/ssh-relay-runtime-windows-no-input-launcher.test.mjs +++ b/config/scripts/ssh-relay-runtime-windows-no-input-launcher.test.mjs @@ -91,6 +91,7 @@ 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 programSource = readFileSync(join(sourceRoot, 'OrcaSshNoInputLauncher.cs'), 'utf8') const consoleSource = readFileSync(join(sourceRoot, 'WindowsPrivateConsoleInput.cs'), 'utf8') const outputSource = readFileSync(join(sourceRoot, 'WindowsBoundedOutputFiles.cs'), 'utf8') @@ -103,6 +104,9 @@ describe('Windows SSH no-input launcher', () => { expect(processSource).toContain('OutputLimitPollMilliseconds = 50') expect(processSource).toContain('JobSettlementTimeoutMilliseconds = 5000') expect(processSource).toContain('TerminateJobObject') + expect(processSource).toContain('diagnosticTimeoutMilliseconds') + expect(programSource).toContain('--diagnostic-timeout-ms') + expect(programSource).toContain('diagnosticTimeoutMilliseconds > 60000') expect(processSource).toContain('outputs.EnsureWithinLimits()') expect(processSource).toContain('outputs.ReplayOutputs()') expect(processSource).not.toContain('WaitForMultipleObjects') @@ -195,6 +199,26 @@ describe('Windows SSH no-input launcher', () => { expect(listCaptureFiles()).toEqual(capturesBefore) }) + itWindows( + 'terminates the child job and replays bounded output at the diagnostic deadline', + { timeout: 10_000 }, + async () => { + const capturesBefore = listCaptureFiles() + const pidPath = join(fixtureRoot, 'timed-out-child.pid') + const result = runLauncher('hold', [pidPath], {}, 250) + const childPid = Number.parseInt(readFileSync(pidPath, 'utf8'), 10) + + expect(result.status).toBe(1) + expect(result.stdout.toString('utf8')).toBe('ORCA-HOLD-STDOUT') + expect(result.stderr.toString('utf8')).toContain('ORCA-HOLD-STDERR') + expect(result.stderr.toString('utf8')).toContain( + 'SSH child reached the 250 ms diagnostic timeout.' + ) + await waitForProcessExit(childPid, 5_000) + expect(listCaptureFiles()).toEqual(capturesBefore) + } + ) + itWindows('does not leak an unrelated inheritable parent handle into the child', () => { const result = spawnSync(handleProbePath, [launcherPath], { cwd: fixtureRoot, @@ -231,15 +255,21 @@ describe('Windows SSH no-input launcher', () => { ) }) -function runLauncher(mode, args = [], options = {}) { - return spawnSync(launcherPath, [process.execPath, childFixturePath, mode, ...args], { - cwd: fixtureRoot, - encoding: null, - timeout: 10_000, - windowsHide: true, - maxBuffer: 32 * 1024 * 1024, - ...options - }) +function runLauncher(mode, args = [], options = {}, diagnosticTimeoutMs = null) { + const diagnosticArgs = + diagnosticTimeoutMs == null ? [] : ['--diagnostic-timeout-ms', String(diagnosticTimeoutMs)] + return spawnSync( + launcherPath, + [...diagnosticArgs, process.execPath, childFixturePath, mode, ...args], + { + cwd: fixtureRoot, + encoding: null, + timeout: 10_000, + windowsHide: true, + maxBuffer: 32 * 1024 * 1024, + ...options + } + ) } function listCaptureFiles() { @@ -328,6 +358,8 @@ if (mode === 'arguments-and-stdin') { } } else if (mode === 'hold') { require('node:fs').writeFileSync(process.argv[3], String(process.pid)) + process.stdout.write('ORCA-HOLD-STDOUT') + process.stderr.write('ORCA-HOLD-STDERR') setInterval(() => {}, 1000) } else { process.exit(99) diff --git a/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist-summary.md b/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist-summary.md index d50e5d6d771..18d7efcc0f4 100644 --- a/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist-summary.md +++ b/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist-summary.md @@ -9,9 +9,9 @@ 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 private- -console plus bounded regular-output Windows diagnostic locally green; exact-head native proof -required, 2026-07-16, Codex implementation owner.** +Active checkpoint: **Milestone 6 / Work Package 5 bounded runtime transfer — bounded verbose +Win32-OpenSSH lifecycle capture locally green; exact-head native proof required, 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 @@ -210,6 +210,21 @@ isolation pass. Seven native launcher cases plus live/full-size behavior remain honest local skips. Commit/push only this runner-temp diagnostic and require fresh exact-head Windows x64/ARM64 compilation, overflow, cleanup, cancellation, live lifecycle, and fixture-teardown proof. SignPath remains deferred. +Exact head `149a122b735659b47d7718f409d14e1ed2f947c2`, run `29553325782`, and x64/ARM64 jobs +`87800290122`/`87800290144` prove 101 files/862 cases with 19 skips, including both new overflow and +capture-cleanup cases, plus unchanged reproducibility and full-size cache gates. The live probes +still time out after 8017.5611/8042.1902 ms with ended stdout, closed channel, no sentinel/process +exit, and zero stderr; both exact teardowns catch locked `UsrClass.dat`. All four non-Windows jobs +and both Linux supplements pass. `E-M6-WINDOWS-PRIVATE-CONSOLE-REGULAR-OUTPUT-CI-RED-001` therefore +falsifies output pipes as the remaining cause. Next add only an internal diagnostic deadline and +bounded replay of verbose OpenSSH output before choosing ConPTY or another client. Production, +Beta, fallback, tuple, publication, default, and signing paths remain untouched. +`E-M6-WINDOWS-VERBOSE-LIFECYCLE-CAPTURE-LOCAL-001` records that diagnostic locally green: an +optional 100-60,000 ms runner-only deadline terminates and settles the owned job before bounded +output replay; the live suite uses 6 seconds, `-vvv`, outer 8/10-second safety ceilings, and a 16 KiB +trace tail. Focused 119/119, all 285 release contracts, typecheck, full lint/reliability/max-lines, +formatting, diff, and protected-resolver isolation pass with honest native skips. Commit/push only +this artifact and require x64/ARM64 phase classification before selecting ConPTY or another client. `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 diff --git a/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist.md b/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist.md index 051eb324023..3c2a337c901 100644 --- a/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist.md +++ b/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist.md @@ -8,7 +8,7 @@ work; keep exact commands, runner identities, hashes, metrics, and residual gaps Date created: 2026-07-14
Last updated: 2026-07-16
-Current phase: Milestone 6 / Work Package 5 bounded runtime transfer — **Locally green / exact-head native proof required — 2026-07-16, Codex implementation owner: disconnected private-console plus bounded regular-output diagnostic**. `E-M6-WINDOWS-PRIVATE-CONSOLE-REGULAR-OUTPUT-LOCAL-001` records the one-variable runner-temp candidate locally green: proven private-console EOF, distinct bounded regular-file output handles, delete-on-close cleanup, bounded job settlement, and post-exit replay. Commit/push only this package and require x64/ARM64 legacy compilation, native contracts, live lifecycle, and exact teardown evidence. Every production/default SSH path remains disconnected. Real Apple/SignPath rehearsals remain deferred.
+Current phase: Milestone 6 / Work Package 5 bounded runtime transfer — **Locally green / bounded verbose client-lifecycle native proof required — 2026-07-16, Codex implementation owner**. `E-M6-WINDOWS-VERBOSE-LIFECYCLE-CAPTURE-LOCAL-001` records the runner-temp internal deadline, job settlement, bounded replay, and verbose trace-tail contracts locally green. Commit/push only this diagnostic and require both native architectures to identify the last completed client phase before selecting ConPTY or another client. Every production/default SSH path remains disconnected. Real Apple/SignPath rehearsals remain deferred.
Session checkpoint: **PRIVATE CONSOLE EOF PROVEN / PRIVATE OUTPUT PIPES FALSIFIED / BOUNDED REGULAR OUTPUT ACTIVE — 2026-07-16, Codex implementation owner** — exact head `5841b12bc94ecd1027e507e4b741e09fcb0a093d`, run `29552101635`, and x64/ARM64 jobs `87796673326`/`87796673353` both pass 101 files/860 cases with 19 skips, legacy compilation, real-console qualification/EOF, argv/binary/exit/handle-exclusion/cancellation, unchanged full-runtime reproducibility, and full-size cache gates. Live diagnostics still time out after 8025.1553/8034.6943 milliseconds with ended stdout, closed channel, no sentinel/process exit, and zero stderr; both exact teardowns catch locked `UsrClass.dat`. All four non-Windows primary jobs and both Linux supplements pass. No product caller, full-size Windows transfer claim, upload, default/fallback, Beta, tuple publication, or signing behavior changed.
Primary design: [SSH relay GitHub Release plan](./2026-07-14-ssh-relay-github-release-plan.html)
Motivating issues: [#8450](https://github.com/stablyai/orca/issues/8450), [#1693](https://github.com/stablyai/orca/issues/1693) @@ -19891,6 +19891,75 @@ config/scripts/ssh-relay-runtime-workflow.test.mjs` passes 119 runnable cases wi Beta option, fallback, full-size Windows transfer claim, tuple, publication, default, signing, or SignPath behavior is authorized. +### E-M6-WINDOWS-PRIVATE-CONSOLE-REGULAR-OUTPUT-CI-RED-001 — regular output files do not settle Win32-OpenSSH + +- Date/owner/head/run: 2026-07-16, Codex implementation owner; exact pushed head + `149a122b735659b47d7718f409d14e1ed2f947c2`, pull-request artifact run `29553325782`, Windows x64 + job `87800290122` on `windows-2022`, and Windows ARM64 job `87800290144` on `windows-11-arm`. +- Native contract proof: both architectures pass legacy .NET Framework compilation, the dedicated + console EOF probe, and 101 artifact-contract files with 862 runnable cases and 19 declared skips. + The two new cases raise the prior count from 860 to 862 and prove >16 MiB fail-closed output plus + successful/overflow delete-on-close cleanup; exact binary replay, argv, exit propagation, + unrelated-handle exclusion, job cancellation, and bounded settlement also pass. Both unchanged + runtimes pass build-twice identity, closure, smoke, and both full-size cache gates. +- X64 live result: `mode=private-console-regular-output-launcher-no-n`, + `elapsedMs=8017.5611`, `success=false`, `timedOut=true`, `sentinel=false`, `stdoutEnded=true`, + `processExit=null`, `channelClosed=true`, `closeCode=null`, and `stderrBytes=0`. +- ARM64 live result: the same lifecycle fields with `elapsedMs=8042.190199999999`. Both official + server logs prove accepted public-key authentication, remote command-session start, and remote + session close before the client requires bounded external termination. The full-size Windows + transfer remains correctly unexecuted after the failed diagnostic. +- Teardown result: x64 cannot unload fixture class hive + `S-1-5-21-4137868374-391603265-4009536220-1003_Classes`; ARM64 independently cannot unload + `S-1-5-21-1882319117-3219095328-2125279949-1001_Classes`. Both catch the locked + `C:\Users\orca_ssh_fixture\AppData\Local\Microsoft\Windows\UsrClass.dat` and retained profile + directory instead of silently leaking fixture state. +- Preserved evidence: Linux x64/ARM64 jobs `87800290119`/`87800290139`, Darwin x64/ARM64 jobs + `87800290123`/`87800290135`, and Linux oldest-userland supplements `87800855219`/`87800855211` + all pass at the same exact head. +- Interpretation/next bound: private pipes, overlapped pipes, zero-length regular stdin, proven + console EOF, and now distinct bounded regular-file stdout/stderr all reproduce the live hang. + Do not select a production mechanism from speculation. Add only a runner-temp internal deadline + that terminates and settles the owned job before replaying bounded output, invoke the exact client + with verbose diagnostics, and record the last completed OpenSSH phase on both architectures. + ConPTY and alternate-client qualification remain subsequent evidence decisions. No product, + Beta, fallback, tuple, publication, default, signing, or SignPath behavior is authorized. + +### E-M6-WINDOWS-VERBOSE-LIFECYCLE-CAPTURE-LOCAL-001 — internal timeout and bounded trace capture are locally green + +- Date/owner/base/runner: 2026-07-16, Codex implementation owner; uncommitted artifact-only package + atop exact regular-output RED head `149a122b735659b47d7718f409d14e1ed2f947c2` on Apple arm64, + macOS 26.2 build 25C56, Node 26.5.0, and pnpm 10.24.0. +- Diagnostic boundary: an optional runner-only `--diagnostic-timeout-ms` accepts 100 through 60,000 + ms and leaves ordinary launcher calls unbounded as before. At deadline the launcher terminates + and waits for its owned job, replays the same bounded regular-file stdout/stderr, then returns a + diagnostic failure. The live purpose suite uses 6,000 ms, retains the existing 8,000/10,000 ms + external safety ceilings, inserts `-vvv` before the exact OpenSSH destination separator, and logs + only a 16 KiB stderr tail plus lifecycle fields. Production argument construction is unchanged. +- Native contract design: the new Windows case writes stdout/stderr plus its PID, holds forever, + and requires internal timeout failure, exact replay, child-job termination, and unchanged capture- + file set. Existing console EOF, argv, binary output, overflow, cleanup, handle exclusion, exit, + and external-cancellation cases remain. The new native case is honestly skipped locally. +- Syntax/focused evidence: both launcher scripts pass `node --check`. + `/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 ten declared + native/live skips in 2.69 seconds real; maximum RSS is 171,212,800 bytes and peak footprint is + 97,179,456 bytes. +- Release/static evidence: the unquoted release-contract command passes 51 files and 285 runnable + cases with eight declared Windows skips in 12.19 seconds. `/usr/bin/time -l pnpm typecheck` + passes in 2.57 seconds real with 1,258,979,328-byte maximum RSS and 97,163,024-byte peak + footprint. `/usr/bin/time -l pnpm lint` passes in 21.90 seconds real with the 26 existing + warnings, all 41 reliability gates, and the 355-entry max-lines ratchet; maximum RSS is + 2,039,169,024 bytes and peak footprint is 97,195,840 bytes. +- Residual boundary: local source contracts do not prove legacy compilation, internal timeout/job + settlement, output replay, or useful OpenSSH verbose content on Windows. Commit/push this isolated + package and require exact-head x64/ARM64 evidence. Do not select ConPTY, another client, a product + caller, Beta, fallback, tuple, publication, default, signing, or SignPath behavior from local proof. + ## Accepted Gaps No product gap is accepted merely because it appears in this list. Each entry requires explicit @@ -19949,11 +20018,10 @@ The project is not complete until every applicable item below is checked with ev ## Next Required Action -Commit and push only the private-console plus bounded regular-output diagnostic recorded by -`E-M6-WINDOWS-PRIVATE-CONSOLE-REGULAR-OUTPUT-LOCAL-001`, then require exact-head Windows x64 and -ARM64 legacy-compiler, console qualification/EOF, bounded binary output/overflow/cleanup, -argv/exit/handle-exclusion/cancellation, live diagnostic, and exact profile/directory teardown -evidence. Do not +Commit and push only the bounded internal-timeout and verbose-output capture recorded by +`E-M6-WINDOWS-VERBOSE-LIFECYCLE-CAPTURE-LOCAL-001`, then require exact-head Windows x64 and +ARM64 legacy-compiler, launcher-timeout settlement, verbose-client phase classification, 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 6f1c2e39203..44b59a7bcec 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 @@ -1670,12 +1670,15 @@ child while retaining private output pipes, exact handle inheritance, job ownership, and bounded cancellation. Native x64/arm64 prove the console handle and zero-byte cooked EOF but the live client still hangs, falsifying stdin alone. - The next one-variable diagnostic retains that console stdin and replaces only + The next one-variable diagnostic retained that console stdin and replaced only child stdout/stderr pipes with unique delete-on-close regular files under an explicit per-stream byte ceiling, bounded polling/cancellation, incremental - post-exit replay, and exact cleanup. Build and live-test it on native x64/arm64 - before considering ConPTY, another SSH client, packaging, signing, or a product - caller. POSIX retains ignored + post-exit replay, and exact cleanup. Native x64/arm64 prove those contracts, but + the live client still hangs with the same lifecycle tuple, falsifying redirected + output pipes as the remaining cause. Before selecting ConPTY or another SSH + client, the runner-only launcher must boundedly terminate its own diagnostic + child and replay an exact verbose OpenSSH trace to classify the stuck client + phase. Do not package, sign, or add 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/OrcaSshNoInputLauncher.cs b/native/windows-ssh-no-input-launcher/OrcaSshNoInputLauncher.cs index 29a4cab4117..ee250c91fb3 100644 --- a/native/windows-ssh-no-input-launcher/OrcaSshNoInputLauncher.cs +++ b/native/windows-ssh-no-input-launcher/OrcaSshNoInputLauncher.cs @@ -7,21 +7,40 @@ internal static class OrcaSshNoInputLauncher { try { - if (args.Length < 1) + int executableIndex = 0; + int diagnosticTimeoutMilliseconds = 0; + if (args.Length >= 2 && args[0] == "--diagnostic-timeout-ms") { - Console.Error.WriteLine("Usage: orca-ssh-no-input.exe [arguments...]"); + if (!Int32.TryParse(args[1], out diagnosticTimeoutMilliseconds) || + diagnosticTimeoutMilliseconds < 100 || + diagnosticTimeoutMilliseconds > 60000) + { + Console.Error.WriteLine("--diagnostic-timeout-ms requires 100 through 60000."); + return 2; + } + executableIndex = 2; + } + if (args.Length <= executableIndex) + { + Console.Error.WriteLine( + "Usage: orca-ssh-no-input.exe [--diagnostic-timeout-ms ] [arguments...]" + ); return 2; } - string executablePath = Path.GetFullPath(args[0]); + string executablePath = Path.GetFullPath(args[executableIndex]); if (!File.Exists(executablePath)) { Console.Error.WriteLine("Unable to locate the SSH executable at \"{0}\".", executablePath); return 2; } - string[] childArgs = new string[args.Length - 1]; - Array.Copy(args, 1, childArgs, 0, childArgs.Length); - return WindowsSshChildProcess.Run(executablePath, childArgs); + string[] childArgs = new string[args.Length - executableIndex - 1]; + Array.Copy(args, executableIndex + 1, childArgs, 0, childArgs.Length); + return WindowsSshChildProcess.Run( + executablePath, + childArgs, + diagnosticTimeoutMilliseconds + ); } catch (Exception error) { diff --git a/native/windows-ssh-no-input-launcher/WindowsSshChildProcess.cs b/native/windows-ssh-no-input-launcher/WindowsSshChildProcess.cs index 493a1c4f94a..4b1b1acd27c 100644 --- a/native/windows-ssh-no-input-launcher/WindowsSshChildProcess.cs +++ b/native/windows-ssh-no-input-launcher/WindowsSshChildProcess.cs @@ -1,5 +1,6 @@ using System; using System.ComponentModel; +using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; using System.Text; @@ -18,7 +19,11 @@ internal static class WindowsSshChildProcess private const uint OutputLimitPollMilliseconds = 50; private const uint JobSettlementTimeoutMilliseconds = 5000; - internal static int Run(string executablePath, string[] args) + internal static int Run( + string executablePath, + string[] args, + int diagnosticTimeoutMilliseconds + ) { Stream launcherStdout = Console.OpenStandardOutput(); Stream launcherStderr = Console.OpenStandardError(); @@ -70,7 +75,22 @@ internal static class WindowsSshChildProcess throw LastWin32("ResumeThread failed."); } - WaitForChildWithinOutputLimits(process.Process, outputs); + bool exited = WaitForChildWithinOutputLimits( + process.Process, + outputs, + diagnosticTimeoutMilliseconds + ); + if (!exited) + { + // Why: the runner must recover verbose bytes without relying on an external + // kill that also destroys the delete-on-close diagnostic captures. + TerminateAndSettleJob(ref job); + outputs.ReplayOutputs(); + throw new TimeoutException( + "SSH child reached the " + diagnosticTimeoutMilliseconds + + " ms diagnostic timeout." + ); + } uint exitCode; if (!GetExitCodeProcess(process.Process, out exitCode)) { @@ -177,18 +197,33 @@ internal static class WindowsSshChildProcess } } - private static void WaitForChildWithinOutputLimits( + private static bool WaitForChildWithinOutputLimits( IntPtr process, - WindowsBoundedOutputFiles outputs + WindowsBoundedOutputFiles outputs, + int diagnosticTimeoutMilliseconds ) { + Stopwatch elapsed = Stopwatch.StartNew(); while (true) { - uint waitResult = WaitForSingleObject(process, OutputLimitPollMilliseconds); + uint waitMilliseconds = OutputLimitPollMilliseconds; + if (diagnosticTimeoutMilliseconds > 0) + { + long remaining = diagnosticTimeoutMilliseconds - elapsed.ElapsedMilliseconds; + if (remaining <= 0) + { + return false; + } + if (remaining < waitMilliseconds) + { + waitMilliseconds = (uint)remaining; + } + } + uint waitResult = WaitForSingleObject(process, waitMilliseconds); if (waitResult == WaitObject0) { outputs.EnsureWithinLimits(); - return; + return true; } if (waitResult == WaitFailed) { 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 8fe45e3a699..f90acfe38f5 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-console-regular-output-launcher-no-n' +type DiagnosticMode = 'private-console-regular-output-verbose-timeout' type DiagnosticResult = { mode: DiagnosticMode @@ -19,6 +19,7 @@ type DiagnosticResult = { channelClosed: boolean closeCode: number | null | 'not-observed' stderrBytes: number + stderrTail: string elapsedMs: number } @@ -48,19 +49,26 @@ async function runDiagnostic(): Promise { if (!sshPath) { throw new Error('Native Windows OpenSSH client is unavailable') } - const mode: DiagnosticMode = 'private-console-regular-output-launcher-no-n' + const mode: DiagnosticMode = 'private-console-regular-output-verbose-timeout' const startedAt = performance.now() // Why: Win32-OpenSSH still hangs with proven console EOF plus piped output, so this diagnostic // isolates stdout/stderr as bounded regular files without changing production transport. + const sshArgs = buildSshArgs(createTarget()) + const destinationSeparator = sshArgs.indexOf('--') + if (destinationSeparator < 0) { + throw new Error('Windows OpenSSH diagnostic arguments lost the destination separator') + } + sshArgs.splice(destinationSeparator, 0, '-vvv') const child = spawn( launcherPath as string, - [sshPath, ...buildSshArgs(createTarget()), 'echo ORCA-SYSTEM-SSH-OK'], + ['--diagnostic-timeout-ms', '6000', sshPath, ...sshArgs, 'echo ORCA-SYSTEM-SSH-OK'], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true } ) return new Promise((resolve, reject) => { let stdout = '' let stderrBytes = 0 + let stderrTail = '' let stdoutEnded = false let processExit: number | null | 'not-observed' = 'not-observed' let channelClosed = false @@ -91,6 +99,7 @@ async function runDiagnostic(): Promise { channelClosed, closeCode, stderrBytes, + stderrTail, elapsedMs: performance.now() - startedAt }) } @@ -115,6 +124,8 @@ async function runDiagnostic(): Promise { }) child.stderr?.on('data', (chunk: Buffer) => { stderrBytes += chunk.length + // Why: retain only the diagnostic tail needed to classify the stuck client phase. + stderrTail = (stderrTail + chunk.toString('utf8')).slice(-16 * 1024) }) child.on('exit', (code) => { processExit = code @@ -138,16 +149,16 @@ async function runDiagnostic(): Promise { describe.skipIf(!hasLiveInput)('Windows OpenSSH no-input child-handle diagnostic', () => { it( - 'qualifies private-console EOF plus regular-file output without the Windows null-input option', + 'captures the verbose client phase after a bounded private-console diagnostic timeout', { timeout: 15_000 }, async () => { expect(process.platform).toBe('win32') - const privateConsoleRegularOutputLauncherNoN = await runDiagnostic() + const privateConsoleRegularOutputVerboseTimeout = await runDiagnostic() console.log( - `ssh_windows_no_input_handle_diagnostic=${JSON.stringify({ privateConsoleRegularOutputLauncherNoN })}` + `ssh_windows_no_input_handle_diagnostic=${JSON.stringify({ privateConsoleRegularOutputVerboseTimeout })}` ) - expect(privateConsoleRegularOutputLauncherNoN.success).toBe(true) + expect(privateConsoleRegularOutputVerboseTimeout.success).toBe(true) } ) })