feat(ssh): adapt system SSH relay file channels

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-07-16 13:12:26 -04:00
co-authored by Orca
parent 62ffaec98d
commit f1d6d92690
6 changed files with 668 additions and 12 deletions
@@ -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 `
@@ -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',
@@ -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
@@ -9,7 +9,7 @@ work; keep exact commands, runner identities, hashes, metrics, and residual gaps
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 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.<br>
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.<br>
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.<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)
@@ -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
@@ -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<typeof vi.fn<(chunk: Buffer, callback: WriteCallback) => boolean>>
end: ReturnType<typeof vi.fn<() => void>>
}
stderr: EventEmitter
resume: ReturnType<typeof vi.fn<() => FakeChannel>>
close: ReturnType<typeof vi.fn<() => void>>
_process?: ChildProcess
}
function createChannel(options: { process?: boolean } = {}): {
channel: FakeChannel
kill: ReturnType<typeof vi.fn<(signal?: NodeJS.Signals | number) => 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<typeof vi.fn<SshRelayRuntimeSystemSshConnection['exec']>>
} {
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')
})
})
@@ -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<ClientChannel>
}>
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<void> {
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<SshRelayRuntimePosixFileChannel> {
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)
}