From aab0775272aca2b03d55d045cbaf7aefc7511bfe Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 16 Jul 2026 20:28:49 -0400 Subject: [PATCH] fix(ssh): detach no-input child stdin Co-authored-by: Orca --- ...elease-implementation-checklist-summary.md | 17 +++++- ...github-release-implementation-checklist.md | 58 ++++++++++++++++++- ...6-07-14-ssh-relay-github-release-plan.html | 7 ++- src/main/ssh/ssh-system-fallback.test.ts | 12 ++++ src/main/ssh/system-ssh-command.ts | 17 ++++-- 5 files changed, 100 insertions(+), 11 deletions(-) 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 699cbe93487..53de3ed671d 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 @@ -104,8 +104,21 @@ and boundedly terminates only processes whose native owner SID exactly matches t by the fixture before attempting hive unload. Focused tests pass 115 cases with one native skip, all three PowerShell blocks parse, 283/283 artifact contracts pass, typecheck and full lint/reliability/max-lines pass, formatting/diff are clean, and protected resolvers are unchanged. -Commit/push and require fresh x64/arm64 lifecycle evidence; do not treat this local oracle as native -proof. +Exact head `e4bcee3f3561e7cfd58aa90e7d35e6c597df8692` is pushed; artifact run `29544129779` is in +progress with x64 job `87772490863` and ARM64 job `87772490900`. Do not treat the local oracle as +native proof. +X64 job `87772490863` is diagnostic RED after official OpenSSH authentication and remote command +session close: `sentinel=false`, `stdoutEnded=false`, `processExit=not-observed`, and +`channelClosed=false`. The client-side no-input process must receive an OS-level ignored stdin +handle in addition to `-n`; preserve piped stdin for every payload-bearing command. ARM64 job +`87772490900` independently returns the same four-field lifecycle tuple after a 31.16-second test, +so the correction is shared across native Windows architectures. +`E-M6-WINDOWS-SYSTEM-SSH-TREE-OS-NOINPUT-LOCAL-001` is locally green: only no-input commands +spawn with stdin ignored at the OS boundary and retain `-n`; ordinary and payload commands remain +piped, and the no-input channel facade settles safely without a child stdin stream. Focused tests +pass 116 cases with one native skip, all three PowerShell blocks parse, 283/283 artifact contracts +pass, typecheck and full lint/reliability/max-lines pass, formatting/diff are clean, and protected +resolvers remain unchanged. Commit/push and require fresh x64/arm64 live proof. `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 bd5c9f6c21b..27a899ffc67 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 @@ -9,7 +9,7 @@ work; keep exact commands, runner identities, hashes, metrics, and residual gaps Date created: 2026-07-14
Last updated: 2026-07-15
Current phase: Milestone 6 / Work Package 5 bounded runtime transfer — **In progress — 2026-07-15, Codex implementation owner: exact-head Windows OpenSSH live-proof diagnosis and fixture correction**. Keep every capability disconnected from every production/default SSH path. Real Apple/SignPath rehearsals remain an explicit late gate and are not on this package's critical path.
-Session checkpoint: **WINDOWS NATIVE PROCESS/SESSION LIFECYCLE DIAGNOSTIC LOCALLY GREEN / EXACT-HEAD PROOF NEXT — 2026-07-15, Codex implementation owner** — exact head `b652c1ebcce3803443d84c89811146366cdbdd5b`, artifact run `29542292712`, x64 job `87766845800`, and ARM64 job `87766845741` preserve all prior artifact/build/cache/hash/closure/signer gates and independently expose the same native probe/profile gaps. Exact corrected head `8ae6dcf6450b5f081788357dd8fe776293fb0cfe`, run `29543292239`, x64 job `87769913124`, and ARM64 job `87769913148` remain RED: official OpenSSH starts, but probes still time out after 31.337/31.06 seconds and both fixture `_Classes` hive unloads return exit 1 before exact `UsrClass.dat`/directory failures. Win32 has no Orca control socket, so this is not an Orca ControlMaster retry path. `E-M6-WINDOWS-SYSTEM-SSH-TREE-LIFECYCLE-DIAGNOSTIC-LOCAL-001` adds non-sensitive sentinel/stdout-end/process-exit/channel-close timeout evidence and bounded exact-fixture-SID process settlement before hive removal; focused, PowerShell parse, 283-contract, typecheck, full lint/reliability/max-lines, formatting, diff, and protected-resolver gates pass locally. Commit/push and require fresh x64/arm64 lifecycle evidence. Do not advance Milestone 6. Production runtime bytes still cross authenticated SSH and no product remote downloads anything. Do not connect a product caller, change legacy upload/fallback/default behavior, enable a tuple, publish an artifact, or perform SignPath work. Merging to `main` remains prohibited, SignPath is deferred, and legacy remains the production default.
+Session checkpoint: **WINDOWS OS-LEVEL NO-INPUT CORRECTION LOCALLY GREEN / EXACT-HEAD PROOF NEXT — 2026-07-15, Codex implementation owner** — exact head `b652c1ebcce3803443d84c89811146366cdbdd5b`, artifact run `29542292712`, x64 job `87766845800`, and ARM64 job `87766845741` preserve all prior artifact/build/cache/hash/closure/signer gates and independently expose the same native probe/profile gaps. Exact corrected head `8ae6dcf6450b5f081788357dd8fe776293fb0cfe`, run `29543292239`, x64 job `87769913124`, and ARM64 job `87769913148` remain RED despite probe-only `-n`. `E-M6-WINDOWS-SYSTEM-SSH-TREE-LIFECYCLE-DIAGNOSTIC-LOCAL-001` adds non-sensitive lifecycle evidence and exact-fixture-SID process settlement; every local gate passes. Exact diagnostic head `e4bcee3f3561e7cfd58aa90e7d35e6c597df8692`, run `29544129779`, x64 job `87772490863`, and ARM64 job `87772490900` prove official OpenSSH authentication and remote command-session close while both clients record `sentinel=false`, `stdoutEnded=false`, `processExit=not-observed`, and `channelClosed=false`. Win32 has no Orca control socket, so this is not a mux path. `E-M6-WINDOWS-SYSTEM-SSH-TREE-OS-NOINPUT-LOCAL-001` gives only no-input commands an OS-level ignored stdin handle while ordinary/payload commands retain a pipe; focused, PowerShell parse, 283-contract, typecheck, full lint/reliability/max-lines, formatting, diff, and protected-resolver gates pass locally. Commit/push and require fresh x64/arm64 live proof. Do not advance Milestone 6. Production runtime bytes still cross authenticated SSH and no product remote downloads anything. Do not connect a product caller, change legacy upload/fallback/default behavior, enable a tuple, publish an artifact, or perform SignPath work. Merging to `main` remains prohibited, SignPath is deferred, and legacy remains the production default.
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) @@ -19245,6 +19245,62 @@ config/scripts/ssh-relay-runtime-*.test.mjs` passes 50 files and 283/283 cases i require fresh exact-head x64/arm64 proof. Legacy remains the sole production/default path; SignPath and product caller/Beta/settings/fallback/tuple/publication work remain deferred. +### E-M6-WINDOWS-SYSTEM-SSH-TREE-LIFECYCLE-CI-RED-001 — both native clients authenticate but never settle inherited stdio + +- Date/owner/head/run: 2026-07-15, Codex implementation owner; exact pushed head + `e4bcee3f3561e7cfd58aa90e7d35e6c597df8692`, artifact run `29544129779`, native x64 job + `87772490863` (`windows-2022`) and native ARM64 job `87772490900` (`windows-11-arm`). +- Preserved evidence: both jobs pass the 100-file / 854-case native artifact suite with 18 declared + skips, exact double-build identity/smoke, full-size client cache, archive/library hash and signer + closure, ACL replacement, official OpenSSH 10.0p2 startup, pinned-key authentication, and + PowerShell 5.1 before the live transfer. +- Shared lifecycle RED: server logs prove accepted public-key authentication, a fixture-user child, + remote command-session start, and remote command-session close. X64 then times out after 31.16 + seconds and ARM64 after 31.16 seconds with the identical client observation: + `sentinel=false`, `stdoutEnded=false`, `processExit=not-observed`, `channelClosed=false`. + No fixture-SID-owned process remains by teardown, yet `_Classes` hive unload still exits 1 and + exact `UsrClass.dat`/profile-directory assertions fail. +- Correction gate: for `SystemSshBuildArgsOptions.noInput` only, spawn the native child with + OS-level ignored stdin in addition to OpenSSH `-n`; keep stdin piped for ordinary commands and + every file/tree payload. Prove ignored-vs-piped spawn options and a safe no-input channel facade, + then rerun focused/283/static/format/isolation and fresh x64/arm64 live gates. Do not weaken the + structured sentinel, process/channel settlement, profile, or directory checks. + +### E-M6-WINDOWS-SYSTEM-SSH-TREE-OS-NOINPUT-LOCAL-001 — no-input commands have no inherited OS pipe while payload commands remain piped + +- Date/owner/base/runner: 2026-07-15, Codex implementation owner; uncommitted correction atop + exact lifecycle RED head `e4bcee3f3561e7cfd58aa90e7d35e6c597df8692` on Apple arm64, + macOS 26.2 build 25C56, Node 26.5.0, pnpm 10.24.0. Native Windows execution remains required. +- Correction: `spawnSystemSshCommand` uses `stdio: ['ignore', 'pipe', 'pipe']` only when + `SystemSshBuildArgsOptions.noInput` is true; it retains OpenSSH `-n`. Every ordinary command and + every file/tree payload keeps `stdio: ['pipe', 'pipe', 'pipe']`. When Node correctly exposes no + child stdin stream, the channel uses its own fail-on-write facade so the probe's ownership-ending + `stdin.end()` settles without fabricating a writable child pipe. +- 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-relay-runtime-windows-system-ssh-openssh-full-size.test.ts +config/scripts/ssh-relay-runtime-workflow.test.mjs` passes 3 files plus one declared native skip, + 116 passed tests plus one skip, in 5.83 seconds real; maximum RSS is 168,116,224 bytes and peak + footprint is 98,637,584 bytes. Independent YAML extraction plus PowerShell + `[scriptblock]::Create()` parses all three start/measure/stop blocks. +- Release-contract command: `/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 50 files and 283/283 cases in 30.72 seconds + real; maximum RSS is 195,543,040 bytes and peak footprint is 97,769,280 bytes. +- Static commands: `/usr/bin/time -l pnpm typecheck` passes in 8.01 seconds real with maximum RSS + 1,846,231,040 bytes and peak footprint 97,752,800 bytes. `/usr/bin/time -l pnpm lint` passes in + 31.04 seconds real with only the 26 pre-existing warnings, all 41 reliability gates, the + 355-entry max-lines ratchet, bundled guides, and localization checks; maximum RSS is + 1,978,155,008 bytes and peak footprint is 97,605,488 bytes. +- Isolation evidence: targeted `oxfmt --check`, `git diff --check`, and exact working-tree equality + to `HEAD` for both protected resolver files pass. Only the no-input system-SSH process/channel + boundary and test plus the three living planning artifacts are added to the prior diagnostic + package. +- Residual boundary: local macOS cannot execute Windows `ssh.exe`, so fresh exact-head x64/arm64 + proof must show sentinel/process/channel settlement, full-size transfer metrics, cancellation, + collision, cleanup, and exact profile/directory teardown. Legacy remains the sole + production/default path; SignPath remains deferred. + ## Accepted Gaps No product gap is accepted merely because it appears in this list. Each entry requires explicit 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 6d0ab9317cc..6eabb581669 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 @@ -1643,9 +1643,10 @@ Full-size runtime over SFTP and POSIX/Windows system SSH; tar/no-tar; no remote hash tools; corrupted upload; bundled-Node corruption/failure; bounded memory and concurrency; no-input system-SSH probe records bounded native process exit, - stdout-end, sentinel, and channel-close settlement rather than treating OpenSSH - -n alone as proof; fixture-owned Windows session processes settle - before profile hives are boundedly unloaded and native profile deletion begins; + stdout-end, sentinel, and channel-close settlement; the child receives an + OS-level ignored stdin handle as well as OpenSSH -n, because the + option alone is not proof; 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/src/main/ssh/ssh-system-fallback.test.ts b/src/main/ssh/ssh-system-fallback.test.ts index b9b6c0940d6..f3d95a55790 100644 --- a/src/main/ssh/ssh-system-fallback.test.ts +++ b/src/main/ssh/ssh-system-fallback.test.ts @@ -418,6 +418,18 @@ describe('spawnSystemSsh', () => { ) }) + it('spawns a no-input command without an inherited stdin pipe', () => { + spawnMock.mockReturnValueOnce({ ...mockProc, stdin: null }) + const channel = spawnSystemSshCommand(createTarget(), 'echo ready', { noInput: true }) + + expect(spawnMock).toHaveBeenCalledWith( + SYSTEM_SSH_PATH, + expect.arrayContaining(['-n', '--', 'deploy@example.com']), + expect.objectContaining({ stdio: ['ignore', 'pipe', 'pipe'] }) + ) + expect(() => channel.stdin.end()).not.toThrow() + }) + it('spawns port forwards before the ssh destination terminator', () => { spawnSystemSshPortForward(createTarget({ configHost: 'fdpass-host' }), 5173, '127.0.0.1', 3000) diff --git a/src/main/ssh/system-ssh-command.ts b/src/main/ssh/system-ssh-command.ts index 1f0ce5ed6df..1d0193509b6 100644 --- a/src/main/ssh/system-ssh-command.ts +++ b/src/main/ssh/system-ssh-command.ts @@ -62,8 +62,11 @@ export function spawnSystemSshCommand( const remoteCommand = options?.wrapCommand === false ? command : wrapRemoteCommandForPosixShell(command) + const noInput = options?.noInput === true const proc = spawn(sshPath, [...buildSshArgs(target, options), remoteCommand], { - stdio: ['pipe', 'pipe', 'pipe'], + // Why: Windows OpenSSH can keep an inherited pipe alive despite `-n`; + // no-input commands must not receive a writable OS handle at all. + stdio: [noInput ? 'ignore' : 'pipe', 'pipe', 'pipe'], windowsHide: true }) return wrapCommandProcess(proc) @@ -94,7 +97,11 @@ function wrapCommandProcess(proc: ChildProcess): SystemSshCommandChannel { proc.stdout?.resume() }, write(chunk, encoding, cb) { - proc.stdin!.write(chunk, encoding, cb) + if (!proc.stdin) { + cb(new Error('System SSH command does not accept stdin')) + return + } + proc.stdin.write(chunk, encoding, cb) } }) const channel = duplex as unknown as SystemSshCommandChannel @@ -105,7 +112,7 @@ function wrapCommandProcess(proc: ChildProcess): SystemSshCommandChannel { _process?: ChildProcess close: () => void } - mutableChannel.stdin = proc.stdin! + mutableChannel.stdin = proc.stdin ?? duplex mutableChannel.stderr = proc.stderr! mutableChannel._process = proc mutableChannel.close = () => { @@ -122,7 +129,7 @@ function wrapCommandProcess(proc: ChildProcess): SystemSshCommandChannel { proc.off('exit', onExit) proc.off('close', onClose) proc.off('error', onProcessError) - proc.stdin!.off('error', onStreamError) + proc.stdin?.off('error', onStreamError) proc.stdout!.off('error', onStreamError) } const fail = (err: Error): void => { @@ -158,7 +165,7 @@ function wrapCommandProcess(proc: ChildProcess): SystemSshCommandChannel { proc.on('exit', onExit) proc.on('close', onClose) proc.on('error', onProcessError) - proc.stdin!.on('error', onStreamError) + proc.stdin?.on('error', onStreamError) proc.stdout!.on('error', onStreamError) return channel