From f1d6d92690d63744f7b2d9d1497ea8a3fcc82329 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 16 Jul 2026 13:12:26 -0400 Subject: [PATCH] feat(ssh): adapt system SSH relay file channels Co-authored-by: Orca --- .../workflows/ssh-relay-runtime-artifacts.yml | 2 + .../ssh-relay-runtime-workflow.test.mjs | 1 + ...elease-implementation-checklist-summary.md | 19 +- ...github-release-implementation-checklist.md | 137 +++++++- ...ay-runtime-system-ssh-file-channel.test.ts | 329 ++++++++++++++++++ ...h-relay-runtime-system-ssh-file-channel.ts | 192 ++++++++++ 6 files changed, 668 insertions(+), 12 deletions(-) create mode 100644 src/main/ssh/ssh-relay-runtime-system-ssh-file-channel.test.ts create mode 100644 src/main/ssh/ssh-relay-runtime-system-ssh-file-channel.ts diff --git a/.github/workflows/ssh-relay-runtime-artifacts.yml b/.github/workflows/ssh-relay-runtime-artifacts.yml index 83d675d4ac0..3ee2724d17c 100644 --- a/.github/workflows/ssh-relay-runtime-artifacts.yml +++ b/.github/workflows/ssh-relay-runtime-artifacts.yml @@ -287,6 +287,7 @@ jobs: src/main/ssh/ssh-relay-runtime-source-scan.test.ts \ src/main/ssh/ssh-relay-runtime-source-stream.test.ts \ src/main/ssh/ssh-relay-runtime-posix-file-destination.test.ts \ + src/main/ssh/ssh-relay-runtime-system-ssh-file-channel.test.ts \ src/main/ssh/ssh-relay-runtime-sftp-file-destination.test.ts \ src/main/ssh/ssh-relay-runtime-sftp-connection-transfer.test.ts \ src/main/ssh/ssh-relay-runtime-sftp-session.test.ts \ @@ -933,6 +934,7 @@ jobs: src/main/ssh/ssh-relay-runtime-source-scan.test.ts ` src/main/ssh/ssh-relay-runtime-source-stream.test.ts ` src/main/ssh/ssh-relay-runtime-posix-file-destination.test.ts ` + src/main/ssh/ssh-relay-runtime-system-ssh-file-channel.test.ts ` src/main/ssh/ssh-relay-runtime-sftp-file-destination.test.ts ` src/main/ssh/ssh-relay-runtime-sftp-connection-transfer.test.ts ` src/main/ssh/ssh-relay-runtime-sftp-session.test.ts ` diff --git a/config/scripts/ssh-relay-runtime-workflow.test.mjs b/config/scripts/ssh-relay-runtime-workflow.test.mjs index 673b8990b4d..c93fb9e2158 100644 --- a/config/scripts/ssh-relay-runtime-workflow.test.mjs +++ b/config/scripts/ssh-relay-runtime-workflow.test.mjs @@ -165,6 +165,7 @@ describe('SSH relay runtime artifact workflow', () => { 'ssh-relay-runtime-source-scan.test.ts', 'ssh-relay-runtime-source-stream.test.ts', 'ssh-relay-runtime-posix-file-destination.test.ts', + 'ssh-relay-runtime-system-ssh-file-channel.test.ts', 'ssh-relay-runtime-sftp-file-destination.test.ts', 'ssh-relay-runtime-sftp-connection-transfer.test.ts', 'ssh-relay-runtime-sftp-session.test.ts', 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 68ee7a29113..5922a3a34ed 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,10 +9,21 @@ 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 POSIX -system-SSH/no-tar per-file boundary is exact-head green; command-channel adapter audit is next, -2026-07-15, Codex implementation owner.** `E-M6-POSIX-SYSTEM-SSH-FILE-LOCAL-001` records 25/25 purpose, -628 broad relay, 281/281 release, and all static/isolation gates. +Active checkpoint: **Milestone 6 / Work Package 5 bounded runtime transfer — authenticated system-SSH +command-channel adapter is locally green and exact-head CI is next, 2026-07-15, Codex implementation +owner.** `E-M6-POSIX-SYSTEM-SSH-CHANNEL-LOCAL-001` records 13/13 purpose, 150/150 focused, 641 broad +relay cases with six declared skips, 281/281 release contracts, typecheck, full lint, formatting, +max-lines, diff, protected-resolver, and no-product-consumer gates. It retains a single cancellation +owner, drains stdout, caps copied stderr at 16 KiB, and exposes idempotent SIGTERM/SIGKILL hooks. It +has no real tree/live/product consumer; exact-head all-six and adjacent Actions evidence is next. +`E-M6-POSIX-SYSTEM-SSH-CHANNEL-AUDIT-001` limits this slice to one already-authenticated system-SSH +`SshConnection.exec()` channel, bounded copied stderr, drained stdout, exact stdin callback/EOF/ +settlement, and idempotent SIGTERM/SIGKILL hooks consumed by the proven single per-file cancellation +owner. The adapter prechecks but does not forward that signal into `exec`, avoiding a duplicate +immediate SIGTERM listener. +No direct spawn, legacy uploader change, real tree/live claim, or product path is included. +`E-M6-POSIX-SYSTEM-SSH-FILE-LOCAL-001` records 25/25 purpose, 628 broad relay, 281/281 release, and all +static/isolation gates. `E-M6-POSIX-SYSTEM-SSH-FILE-CI-001` proves the 25 cases on all six primary native clients; both Linux supplements, Windows x64 floor, PR Checks, computer-use Windows/Ubuntu, and Golden E2E macOS/Linux pass. Windows arm64 retains only hosted build 26200 versus required 26100 after complete runtime 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 241907c6a85..44732f12823 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: disconnected POSIX system-SSH/no-tar per-file command and destination contract**. 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: **POSIX NO-TAR FILE BOUNDARY EXACT-HEAD GREEN / COMMAND-CHANNEL ADAPTER AUDIT NEXT — 2026-07-15, Codex implementation owner** — `E-M6-POSIX-SYSTEM-SSH-FILE-CI-001` proves the 25-case purpose suite on all six primary native clients; both Linux supplements, Windows x64 floor, PR Checks, computer-use, and Golden E2E pass. Windows arm64 retains only the declared hosted build-26200 versus required-26100 rejection after full runtime smoke. The completed module has no real SSH/tree/product caller. Audit the next smallest disconnected command-channel adapter slice before adding RED tests; do not connect a product/default path. Merging to `main` remains prohibited, SignPath is deferred, and legacy remains the production default.
+Session checkpoint: **SYSTEM-SSH COMMAND-CHANNEL ADAPTER LOCALLY GREEN / EXACT-HEAD CI NEXT — 2026-07-15, Codex implementation owner** — `E-M6-POSIX-SYSTEM-SSH-CHANNEL-LOCAL-001` proves the adapter and per-file composition across 13 purpose cases, 150 focused cases, 641 broad relay cases, 281 release contracts, typecheck, lint, formatting, max-lines, diff, protected-resolver, and no-product-consumer gates. It remains disconnected and has no tree or live system-SSH claim. Commit/push and require all-six native plus adjacent exact-head evidence before the next package. 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) @@ -153,10 +153,12 @@ same change as the work it records. - Completed package: the disconnected POSIX no-tar per-file command/destination boundary is green locally and on every primary native client under E-M6-POSIX-SYSTEM-SSH-FILE-LOCAL-001 and E-M6-POSIX-SYSTEM-SSH-FILE-CI-001. It has no real SSH or product caller. -- Active package: **Audit next — 2026-07-15, Codex implementation owner** — establish the smallest - disconnected adapter between one authenticated system-SSH exec channel and the proven per-file - destination. Do not import or modify the legacy tar/base64 directory uploader, create a remote - tree, add a semantic probe, or add product/settings/fallback/tuple/publication/default behavior. +- Active package: **Local implementation complete / exact-head CI pending — 2026-07-15, Codex + implementation owner** — the purpose-named adapter fixed by + E-M6-POSIX-SYSTEM-SSH-CHANNEL-AUDIT-001 is green under + E-M6-POSIX-SYSTEM-SSH-CHANNEL-LOCAL-001. Do not import or modify the legacy tar/base64 directory + uploader, create a remote tree, add a semantic probe, or add product/settings/fallback/tuple/ + publication/default behavior. - Completed Work Package 2 gate: target-native Windows source-signature reports from exact-head artifact jobs 87267322867 and 87267322870 were independently downloaded and matched to their identities and signing-stage reports under E-M3-WINDOWS-SOURCE-SIGNATURE-CI-001. PR Checks @@ -17620,6 +17622,125 @@ config/scripts/ssh-relay-runtime-workflow.test.mjs`. Beta mode, fallback, tuple, publication, default behavior, or SignPath work exists; legacy remains the only production path. +### E-M6-POSIX-SYSTEM-SSH-CHANNEL-AUDIT-001 — authenticated exec-channel adapter boundary + +- Date/owner/base: 2026-07-15, Codex implementation owner; clean exact base + `62ffaec98df827a5a502f55a2c4a7e1cb7d03d03` after recording the completed file-boundary evidence. +- Existing-code audit: `SshConnection.exec(command)` already preserves the connected + target, resolved OpenSSH configuration, host-scoped ControlMaster disable/retry state, command + tracking, and disconnect/reconnect teardown. Guarding `usesSystemSshTransport()` before that call + prevents accidental ssh2/SFTP-family use. `spawnSystemSshCommand` then wraps the local OpenSSH + child as a `ClientChannel`, exposes stdin/stderr plus its owned child process, forwards close/error, + and keeps stdout backpressure bounded when a consumer drains it. +- Rejected reuse: `system-ssh-file-binary-transfer.ts` is a proven legacy/default whole-buffer/local- + file uploader and must remain unchanged. `waitForChannelClose` accumulates arbitrary stderr, while + `awaitWithSystemSshAbort` deliberately returns immediately after SIGTERM instead of awaiting + process settlement. Those contracts are correct for legacy callers but do not meet the new + bootstrap's capped-memory and pre-execution cancellation boundary; do not import or modify them. +- New boundary: add one purpose-named disconnected adapter module that accepts only a connection, + exact command, and exact signal. It rejects a non-system transport or pre-aborted signal before + exec, calls `connection.exec(command)` exactly once, drains unused stdout immediately, + and returns `SshRelayRuntimePosixFileChannel`. Writes forward the exact borrowed chunk and callback + to child stdin; EOF ends child stdin once; settlement listens to child error/close, requires exit + zero, and includes at most a small fixed copied stderr prefix plus a truncation marker without + retaining an attacker-sized chunk. +- Single cancellation owner: do not pass the signal into `SshConnection.exec`. The proven per-file + destination already owns that exact signal and its bounded SIGTERM/SIGKILL sequence; passing it to + the connection would install a second immediate SIGTERM listener on the same child. Connection- + level disconnect/reconnect still closes every tracked command independently. +- Cancellation ownership: the adapter exposes idempotent graceful close through the tracked + channel's SIGTERM `close()` and idempotent forced close through its owned child process's + `kill('SIGKILL')`. Missing child-process ownership or a thrown kill fails closed. The proven + `ssh-relay-runtime-posix-file-destination.ts` owner retains the 250 ms graceful / 2,000 ms total + settlement timers and decides when to escalate; the adapter must not add a second timer or return + success before the process emits close. +- Layer-A RED/green proof: purpose cases must cover system-transport/pre-abort rejection with zero + exec; exact command, signal precheck, and one exec; stdout drain; exact non-empty chunk/callback and + stdin EOF; + zero/nonzero/signal/error settlement; bounded copied stderr/truncation; listener cleanup; duplicate + graceful/forced calls; SIGTERM before SIGKILL; exited-child force no-op; missing process and thrown + close/kill failures; and composition with the per-file owner for retained-write cancellation and + forced/timeout settlement. Pin the purpose suite exactly once in both native workflow families + before implementation. +- Explicit residual boundary: no direct spawn, SshConnection modification, ssh2/SFTP path, directory + or tree/root staging, primitive semantic probe, tar optimization, `MaxSessions=1` concurrency + policy, full-size/live system-SSH transfer, Windows PowerShell path, remote verification/install/ + publish/launch, product/Beta/settings/fallback/tuple/publication/default behavior, or SignPath is + included. Legacy remains the sole production/default path. + +### E-M6-POSIX-SYSTEM-SSH-CHANNEL-LOCAL-RED-001 — missing adapter fails as designed + +- Date/owner/base: 2026-07-15, Codex implementation owner; exact uncommitted test/workflow state on + base `62ffaec98df827a5a502f55a2c4a7e1cb7d03d03` after + E-M6-POSIX-SYSTEM-SSH-CHANNEL-AUDIT-001. +- Command: `/usr/bin/time -l pnpm exec vitest run --config config/vitest.config.ts +--maxWorkers=1 src/main/ssh/ssh-relay-runtime-system-ssh-file-channel.test.ts +config/scripts/ssh-relay-runtime-workflow.test.mjs`. +- Result: expected RED, exit 1. The new 311-line purpose suite collects zero cases and fails solely + with `Cannot find module './ssh-relay-runtime-system-ssh-file-channel'`; that production module does + not exist yet. The independent release-workflow oracle passes all 8/8 cases and proves the suite is + pinned exactly once in both native command families. +- Resource evidence: Vitest duration 539 ms; `/usr/bin/time -l` reports 1.69 seconds real, + 133,103,616 bytes maximum resident set size, zero swaps, and zero block I/O. +- Disposition: RED is causally narrow and accepted. Implement only the audited adapter, then require + purpose composition GREEN plus focused, broad, static, isolation, and exact-head native evidence. + No live system-SSH, tree, product, or default-path claim follows from this evidence. + +### E-M6-POSIX-SYSTEM-SSH-CHANNEL-LOCAL-TYPE-RED-001 — typed stream boundary corrected + +- Date/owner/state: 2026-07-15, Codex implementation owner; first static pass on the uncommitted + adapter atop `62ffaec98df827a5a502f55a2c4a7e1cb7d03d03` after runtime purpose GREEN. +- Command/result: `/usr/bin/time -l pnpm typecheck` — gate RED, exit 1 in 1.93 seconds real with + 1,044,365,312-byte maximum RSS. TypeScript reports fake `ClientChannel`/stream intersections, + deliberate rejected-promise narrowing, a readonly fake exit field, and the overloaded Node stdin + callback signature; no runtime assertion fails. +- Correction: keep the fake structural until the `ClientChannel` return boundary, narrow the already- + validated child stdin to the exact Buffer/callback/end shape, explicitly narrow the caught error, + and retain mutable fake process state. The repeated 13-case purpose suite and typecheck below pass. + No behavior, lifecycle timer, product path, or legacy module changed. + +### E-M6-POSIX-SYSTEM-SSH-CHANNEL-LOCAL-001 — authenticated child-channel adapter is locally green + +- Date/owner/environment/base: 2026-07-15, Codex implementation owner; local macOS arm64, Node + 26.0.0, pnpm 10.24.0, uncommitted package atop exact base + `62ffaec98df827a5a502f55a2c4a7e1cb7d03d03`. +- Implementation: the 192-line adapter rejects non-system transport and pre-abort before exec, calls + the already-authenticated/tracked `connection.exec(command)` exactly once, and deliberately keeps + cancellation single-owned by the per-file destination. It drains unused stdout, forwards the exact + borrowed Buffer/callback and EOF to child stdin, requires exit zero, copies/caps stderr at 16 KiB, + cleans all diagnostic/settlement listeners, and exposes idempotent SIGTERM/SIGKILL hooks. Missing + process ownership, false/throwing force signals, and termination failures fail closed. +- Purpose command: `/usr/bin/time -l pnpm exec vitest run --config config/vitest.config.ts +--maxWorkers=1 src/main/ssh/ssh-relay-runtime-system-ssh-file-channel.test.ts` — PASS, 13/13 in + 346 ms Vitest / 1.33 seconds real, 131,727,360-byte maximum RSS, zero swaps, and zero block I/O. + Cases cover transport/pre-abort rejection, exact one exec, stdout drain, retained chunk callback, + EOF, successful/nonzero/signaled/error settlement, copied capped diagnostic, listener cleanup, + idempotent ordered termination, exited/missing/unsignaled/thrown process cases, graceful retained- + write composition, and forced composition using only the file owner's timer. +- Focused command: adapter, per-file destination, existing system-SSH fallback, `SshConnection`, and + native workflow oracle — PASS, five files and 150/150 cases in 2.95 seconds Vitest / 4.31 seconds + real with 156,680,192-byte maximum RSS. The workflow oracle passes 8/8 and pins the purpose suite + exactly once in POSIX and PowerShell native command families. +- Broad commands: `src/main/ssh/ssh-relay-*.test.ts` — PASS, 48 files plus four declared skipped, + 641 cases plus six declared skips in 32.50 seconds Vitest / 34.59 seconds real with + 248,201,216-byte maximum RSS; `config/scripts/ssh-relay-runtime-*.test.mjs` — PASS, 50 files and + 281/281 cases in 21.88 seconds Vitest / 23.87 seconds real with 190,169,088-byte maximum RSS. Only + the established stale-lock diagnostic and local Node 26 module deprecation appear. +- Static/isolation commands: repeated `/usr/bin/time -l pnpm typecheck` passes in 3.11 seconds real + with 1,209,237,504-byte maximum RSS. Targeted `oxlint`, targeted `oxfmt --check`, standalone + max-lines ratchet, `git diff --check`, protected-resolver empty diff, and no non-test consumer pass. + Full `pnpm lint` passes in 19.61 seconds real with 2,067,415,040-byte maximum RSS: switch + exhaustiveness, 41 reliability gates, the 355-entry max-lines ratchet, bundled-skill verification, + 9,837 localization references, locale parity, and zero-candidate localization coverage pass; only + pre-existing unrelated warnings remain. Module/test sizes are 192/329 lines with no bypass. +- Residual: this proves an authenticated connection/child callback contract and its file-owner + composition, not a real local OpenSSH process, remote shell, directory/tree/root staging, + primitive semantics, full-size system-SSH transfer, `MaxSessions=1`, or Windows PowerShell path. + It has no non-test consumer. Remote verification/install/publish/launch, product/Beta/settings/ + fallback/tuple/publication/default behavior, and SignPath remain absent; legacy remains the only + production path. Exact-head all-six native and adjacent Actions evidence is required before the + next package. + ## Accepted Gaps No product gap is accepted merely because it appears in this list. Each entry requires explicit @@ -17678,9 +17799,9 @@ The project is not complete until every applicable item below is checked with ev ## Next Required Action -Audit the smallest disconnected authenticated system-SSH exec-channel adapter that can satisfy the -proven POSIX per-file boundary, record its exact ownership/cancellation/residual contract, then add -the purpose-named RED suite and native workflow oracle. Do not +Commit and push the locally green authenticated system-SSH exec-channel adapter, then require exact- +head all-six native artifact jobs, both Linux supplements, Windows x64 floor, PR Checks, computer- +use, and Golden E2E before auditing the next tree/root composition package. Do not check the broad Milestone 6 SFTP or POSIX system-SSH items yet. Do not add an Electron/startup/product importer, per-target mode wiring, fallback, tuple enablement, release publication, or default behavior. Keep Node upstream `.tar.xz` inputs, Windows ZIP, `ORCA_RELAY_PATH`, existing desktop required-assets diff --git a/src/main/ssh/ssh-relay-runtime-system-ssh-file-channel.test.ts b/src/main/ssh/ssh-relay-runtime-system-ssh-file-channel.test.ts new file mode 100644 index 00000000000..567c40cd488 --- /dev/null +++ b/src/main/ssh/ssh-relay-runtime-system-ssh-file-channel.test.ts @@ -0,0 +1,329 @@ +import { EventEmitter } from 'node:events' +import type { ChildProcess } from 'node:child_process' +import type { ClientChannel } from 'ssh2' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + openSshRelayRuntimePosixFileDestination, + SSH_RELAY_RUNTIME_POSIX_FILE_DESTINATION_LIMITS +} from './ssh-relay-runtime-posix-file-destination' +import { + openSshRelayRuntimeSystemSshFileChannel, + SSH_RELAY_RUNTIME_SYSTEM_SSH_FILE_CHANNEL_LIMITS, + type SshRelayRuntimeSystemSshConnection +} from './ssh-relay-runtime-system-ssh-file-channel' + +type WriteCallback = (error?: Error) => void + +type FakeChannel = EventEmitter & { + stdin: EventEmitter & { + write: ReturnType boolean>> + end: ReturnType void>> + } + stderr: EventEmitter + resume: ReturnType FakeChannel>> + close: ReturnType void>> + _process?: ChildProcess +} + +function createChannel(options: { process?: boolean } = {}): { + channel: FakeChannel + kill: ReturnType boolean>> + process: { exitCode: number | null; signalCode: NodeJS.Signals | null } +} { + const emitter = new EventEmitter() as FakeChannel + const stdin = Object.assign(new EventEmitter(), { + write: vi.fn((_chunk: Buffer, callback: WriteCallback) => { + callback() + return true + }), + end: vi.fn() + }) + const kill = vi.fn(() => true) + emitter.stdin = stdin + emitter.stderr = new EventEmitter() + emitter.resume = vi.fn(() => emitter) + emitter.close = vi.fn() + const process = { exitCode: null, signalCode: null, kill } + if (options.process !== false) { + emitter._process = process as unknown as ChildProcess + } + return { channel: emitter, kill, process } +} + +function createConnection( + channel: FakeChannel, + systemTransport = true +): SshRelayRuntimeSystemSshConnection & { + exec: ReturnType> +} { + return { + usesSystemSshTransport: vi.fn(() => systemTransport), + exec: vi.fn(async () => channel as unknown as ClientChannel) + } +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('SSH relay runtime system-SSH file channel', () => { + it('rejects non-system transport and pre-abort before exec', async () => { + const { channel } = createChannel() + const nonSystem = createConnection(channel, false) + + await expect( + openSshRelayRuntimeSystemSshFileChannel( + nonSystem, + 'cat command', + new AbortController().signal + ) + ).rejects.toThrow(/system ssh/i) + expect(nonSystem.exec).not.toHaveBeenCalled() + + const cancelled = createConnection(channel) + const controller = new AbortController() + const reason = new Error('cancelled before exec') + controller.abort(reason) + await expect( + openSshRelayRuntimeSystemSshFileChannel(cancelled, 'cat command', controller.signal) + ).rejects.toBe(reason) + expect(cancelled.exec).not.toHaveBeenCalled() + }) + + it('opens once with the exact command and keeps cancellation single-owned', async () => { + const { channel } = createChannel() + const connection = createConnection(channel) + const controller = new AbortController() + const adapted = await openSshRelayRuntimeSystemSshFileChannel( + connection, + 'exact remote command', + controller.signal + ) + + expect(connection.exec).toHaveBeenCalledOnce() + expect(connection.exec).toHaveBeenCalledWith('exact remote command') + expect(channel.resume).toHaveBeenCalledOnce() + channel.emit('close', 0, null) + await expect(adapted.settled).resolves.toBeUndefined() + expect(channel.listenerCount('error')).toBe(0) + expect(channel.listenerCount('close')).toBe(0) + expect(channel.stderr.listenerCount('data')).toBe(0) + expect(channel.stderr.listenerCount('error')).toBe(0) + }) + + it('forwards the exact borrowed chunk callback and EOF to child stdin', async () => { + const { channel } = createChannel() + let retainedCallback: WriteCallback | undefined + channel.stdin.write.mockImplementationOnce((_chunk, callback) => { + retainedCallback = callback + return false + }) + const adapted = await openSshRelayRuntimeSystemSshFileChannel( + createConnection(channel), + 'cat command', + new AbortController().signal + ) + const chunk = Buffer.alloc(64 * 1024, 3) + const callback = vi.fn() + + adapted.write(chunk, callback) + expect(channel.stdin.write).toHaveBeenCalledWith(chunk, callback) + expect(callback).not.toHaveBeenCalled() + retainedCallback?.() + expect(callback).toHaveBeenCalledOnce() + adapted.end() + expect(channel.stdin.end).toHaveBeenCalledOnce() + channel.emit('close', 0, null) + await adapted.settled + }) + + it.each([ + [1, null, 'exit 1'], + [null, 'SIGTERM', 'signal SIGTERM'] + ] as const)('rejects nonzero settlement %j/%j', async (code, signal, expected) => { + const { channel } = createChannel() + const adapted = await openSshRelayRuntimeSystemSshFileChannel( + createConnection(channel), + 'cat command', + new AbortController().signal + ) + + channel.stderr.emit('data', Buffer.from('remote cat failed')) + channel.emit('close', code, signal) + await expect(adapted.settled).rejects.toThrow(expected) + await expect(adapted.settled).rejects.toThrow('remote cat failed') + }) + + it.each(['channel', 'stderr'] as const)( + 'propagates %s errors and removes listeners', + async (from) => { + const { channel } = createChannel() + const adapted = await openSshRelayRuntimeSystemSshFileChannel( + createConnection(channel), + 'cat command', + new AbortController().signal + ) + const error = new Error(`${from} failed`) + + if (from === 'channel') { + channel.emit('error', error) + } else { + channel.stderr.emit('error', error) + } + await expect(adapted.settled).rejects.toBe(error) + expect(channel.listenerCount('error')).toBe(0) + expect(channel.listenerCount('close')).toBe(0) + expect(channel.stderr.listenerCount('data')).toBe(0) + expect(channel.stderr.listenerCount('error')).toBe(0) + } + ) + + it('copies and caps attacker-sized stderr with an explicit marker', async () => { + const { channel } = createChannel() + const adapted = await openSshRelayRuntimeSystemSshFileChannel( + createConnection(channel), + 'cat command', + new AbortController().signal + ) + const diagnostic = Buffer.alloc( + SSH_RELAY_RUNTIME_SYSTEM_SSH_FILE_CHANNEL_LIMITS.diagnosticBytes * 4, + 's' + ) + + channel.stderr.emit('data', diagnostic) + diagnostic.fill('x') + channel.emit('close', 9, null) + const error = await adapted.settled.catch((reason: Error) => reason) + expect(error).toBeInstanceOf(Error) + if (!(error instanceof Error)) { + throw new Error('expected system SSH settlement failure') + } + expect(error.message).toContain('sss') + expect(error.message).not.toContain('xxx') + expect(error.message).toContain('truncated') + expect(Buffer.byteLength(error.message)).toBeLessThanOrEqual( + SSH_RELAY_RUNTIME_SYSTEM_SSH_FILE_CHANNEL_LIMITS.diagnosticBytes + 200 + ) + }) + + it('makes graceful and forced close idempotent and ordered', async () => { + const { channel, kill } = createChannel() + const adapted = await openSshRelayRuntimeSystemSshFileChannel( + createConnection(channel), + 'cat command', + new AbortController().signal + ) + void adapted.settled.catch(() => {}) + + adapted.requestClose() + adapted.requestClose() + adapted.forceClose() + adapted.forceClose() + expect(channel.close).toHaveBeenCalledOnce() + expect(kill).toHaveBeenCalledOnce() + expect(kill).toHaveBeenCalledWith('SIGKILL') + expect(channel.close.mock.invocationCallOrder[0]).toBeLessThan(kill.mock.invocationCallOrder[0]) + }) + + it('does not force an exited child and fails closed without process ownership or signaling', async () => { + const exited = createChannel() + exited.process.exitCode = 0 + const exitedAdapter = await openSshRelayRuntimeSystemSshFileChannel( + createConnection(exited.channel), + 'cat command', + new AbortController().signal + ) + void exitedAdapter.settled.catch(() => {}) + exitedAdapter.forceClose() + expect(exited.kill).not.toHaveBeenCalled() + + const missing = createChannel({ process: false }) + const missingAdapter = await openSshRelayRuntimeSystemSshFileChannel( + createConnection(missing.channel), + 'cat command', + new AbortController().signal + ) + void missingAdapter.settled.catch(() => {}) + expect(() => missingAdapter.forceClose()).toThrow(/process ownership/i) + + const unsignaled = createChannel() + unsignaled.kill.mockReturnValue(false) + const unsignaledAdapter = await openSshRelayRuntimeSystemSshFileChannel( + createConnection(unsignaled.channel), + 'cat command', + new AbortController().signal + ) + void unsignaledAdapter.settled.catch(() => {}) + expect(() => unsignaledAdapter.forceClose()).toThrow(/forced termination/i) + }) + + it('propagates thrown graceful and forced termination failures once', async () => { + const { channel, kill } = createChannel() + channel.close.mockImplementation(() => { + throw new Error('SIGTERM failed') + }) + kill.mockImplementation(() => { + throw new Error('SIGKILL failed') + }) + const adapted = await openSshRelayRuntimeSystemSshFileChannel( + createConnection(channel), + 'cat command', + new AbortController().signal + ) + void adapted.settled.catch(() => {}) + + expect(() => adapted.requestClose()).toThrow('SIGTERM failed') + expect(() => adapted.requestClose()).not.toThrow() + expect(() => adapted.forceClose()).toThrow('SIGKILL failed') + expect(() => adapted.forceClose()).not.toThrow() + }) + + it('composes retained-write cancellation through graceful process settlement', async () => { + const { channel, kill } = createChannel() + channel.stdin.write.mockImplementation(() => true) + channel.close.mockImplementation(() => channel.emit('close', null, 'SIGTERM')) + const connection = createConnection(channel) + const controller = new AbortController() + const reason = new Error('cancelled retained system SSH write') + const destination = await openSshRelayRuntimePosixFileDestination({ + remotePath: '/owned-stage/bin/node', + mode: 0o755, + signal: controller.signal, + openChannel: (command, signal) => + openSshRelayRuntimeSystemSshFileChannel(connection, command, signal) + }) + const writing = destination.write(Buffer.alloc(64 * 1024, 5)) + + controller.abort(reason) + await expect(writing).rejects.toBe(reason) + await expect(destination.abort(reason)).resolves.toBeUndefined() + expect(channel.close).toHaveBeenCalled() + expect(kill).not.toHaveBeenCalled() + }) + + it('composes forced settlement without adding a second timer', async () => { + vi.useFakeTimers() + const { channel, kill } = createChannel() + kill.mockImplementation(() => { + channel.emit('close', null, 'SIGKILL') + return true + }) + const controller = new AbortController() + const destination = await openSshRelayRuntimePosixFileDestination({ + remotePath: '/owned-stage/bin/node', + mode: 0o755, + signal: controller.signal, + openChannel: (command, signal) => + openSshRelayRuntimeSystemSshFileChannel(createConnection(channel), command, signal) + }) + + controller.abort(new Error('cancelled')) + const aborting = destination.abort(controller.signal.reason) + await vi.advanceTimersByTimeAsync( + SSH_RELAY_RUNTIME_POSIX_FILE_DESTINATION_LIMITS.gracefulCloseMs + ) + await expect(aborting).resolves.toBeUndefined() + expect(channel.close).toHaveBeenCalledOnce() + expect(kill).toHaveBeenCalledWith('SIGKILL') + }) +}) diff --git a/src/main/ssh/ssh-relay-runtime-system-ssh-file-channel.ts b/src/main/ssh/ssh-relay-runtime-system-ssh-file-channel.ts new file mode 100644 index 00000000000..ace3381340d --- /dev/null +++ b/src/main/ssh/ssh-relay-runtime-system-ssh-file-channel.ts @@ -0,0 +1,192 @@ +import type { ClientChannel } from 'ssh2' +import type { SshRelayRuntimePosixFileChannel } from './ssh-relay-runtime-posix-file-destination' +import type { SystemSshCommandChannel } from './system-ssh-command' + +export type SshRelayRuntimeSystemSshConnection = Readonly<{ + usesSystemSshTransport: () => boolean + exec: (command: string) => Promise +}> + +export const SSH_RELAY_RUNTIME_SYSTEM_SSH_FILE_CHANNEL_LIMITS = Object.freeze({ + diagnosticBytes: 16 * 1024 +}) + +type ChannelCloseCode = number | null +type ChannelCloseSignal = NodeJS.Signals | null | undefined + +function validateInput( + connection: SshRelayRuntimeSystemSshConnection, + command: string, + signal: AbortSignal +): void { + if ( + !connection || + typeof connection.usesSystemSshTransport !== 'function' || + typeof connection.exec !== 'function' || + typeof command !== 'string' || + command === '' || + !signal + ) { + throw new Error('SSH relay runtime system SSH file channel input is invalid') + } + if (!connection.usesSystemSshTransport()) { + throw new Error('SSH relay runtime system SSH file channel requires system SSH transport') + } + signal.throwIfAborted() +} + +function validateChannel(channel: SystemSshCommandChannel): void { + if ( + !channel || + typeof channel.on !== 'function' || + typeof channel.off !== 'function' || + typeof channel.resume !== 'function' || + typeof channel.close !== 'function' || + !channel.stdin || + typeof channel.stdin.write !== 'function' || + typeof channel.stdin.end !== 'function' || + !channel.stderr || + typeof channel.stderr.on !== 'function' || + typeof channel.stderr.off !== 'function' + ) { + throw new Error('SSH relay runtime system SSH command channel is invalid') + } +} + +function appendDiagnostic( + chunks: Buffer[], + size: number, + data: Buffer | string +): { size: number; truncated: boolean } { + const bytes = Buffer.isBuffer(data) ? data : Buffer.from(data) + const remaining = SSH_RELAY_RUNTIME_SYSTEM_SSH_FILE_CHANNEL_LIMITS.diagnosticBytes - size + if (remaining > 0) { + // Why: copying avoids retaining an attacker-sized parent buffer for a small diagnostic prefix. + const copied = Buffer.from(bytes.subarray(0, remaining)) + chunks.push(copied) + size += copied.length + } + return { size, truncated: bytes.length > remaining } +} + +function diagnosticText(chunks: readonly Buffer[], truncated: boolean): string { + const text = Buffer.concat(chunks).toString('utf8').trim() + if (!truncated) { + return text + } + return `${text}${text ? ' ' : ''}[truncated]` +} + +function settlementError( + code: ChannelCloseCode, + signal: ChannelCloseSignal, + diagnostic: string +): Error { + const detail = code === null ? `signal ${signal ?? 'unknown'}` : `exit ${code}` + return new Error( + `SSH relay runtime system SSH file command failed (${detail})${diagnostic ? `: ${diagnostic}` : ''}` + ) +} + +function waitForSettlement(channel: SystemSshCommandChannel): Promise { + return new Promise((resolve, reject) => { + const diagnosticChunks: Buffer[] = [] + let diagnosticSize = 0 + let diagnosticTruncated = false + let complete = false + + const cleanup = (): void => { + channel.off('error', onError) + channel.off('close', onClose) + channel.stderr.off('data', onStderrData) + channel.stderr.off('error', onError) + } + const settle = (callback: () => void): void => { + if (complete) { + return + } + complete = true + cleanup() + callback() + } + const onError = (error: Error): void => { + settle(() => reject(error)) + } + const onStderrData = (data: Buffer | string): void => { + const appended = appendDiagnostic(diagnosticChunks, diagnosticSize, data) + diagnosticSize = appended.size + diagnosticTruncated ||= appended.truncated + } + const onClose = (code: ChannelCloseCode, signal?: ChannelCloseSignal): void => { + settle(() => { + if (code === 0) { + resolve() + return + } + reject(settlementError(code, signal, diagnosticText(diagnosticChunks, diagnosticTruncated))) + }) + } + + channel.on('error', onError) + channel.on('close', onClose) + channel.stderr.on('data', onStderrData) + channel.stderr.on('error', onError) + }) +} + +function adaptChannel(channel: SystemSshCommandChannel): SshRelayRuntimePosixFileChannel { + validateChannel(channel) + const settled = waitForSettlement(channel) + const input = channel.stdin as unknown as { + write: (chunk: Buffer, callback: (error?: Error) => void) => void + end: () => void + } + let closeRequested = false + let forceRequested = false + + // Why: the remote command has no stdout contract; draining prevents login noise from deadlocking. + channel.resume() + + const write = (chunk: Buffer, callback: (error?: Error) => void): void => { + input.write(chunk, callback) + } + const end = (): void => { + input.end() + } + const requestClose = (): void => { + if (closeRequested) { + return + } + closeRequested = true + channel.close() + } + const forceClose = (): void => { + if (forceRequested) { + return + } + forceRequested = true + const process = channel._process + if (!process) { + throw new Error('SSH relay runtime system SSH file channel process ownership is missing') + } + if (process.exitCode !== null || process.signalCode !== null) { + return + } + if (!process.kill('SIGKILL')) { + throw new Error('SSH relay runtime system SSH file channel forced termination failed') + } + } + + return Object.freeze({ write, end, settled, requestClose, forceClose }) +} + +export async function openSshRelayRuntimeSystemSshFileChannel( + connection: SshRelayRuntimeSystemSshConnection, + command: string, + signal: AbortSignal +): Promise { + validateInput(connection, command, signal) + // Why: the file destination owns this signal's bounded teardown; forwarding it would double-kill. + const channel = (await connection.exec(command)) as SystemSshCommandChannel + return adaptChannel(channel) +}