feat: add bounded SSH relay SFTP sessions

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-07-16 11:14:50 -04:00
co-authored by Orca
parent d82a8abb1c
commit fbec34cf12
8 changed files with 632 additions and 12 deletions
@@ -286,6 +286,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-sftp-file-destination.test.ts \
src/main/ssh/ssh-relay-runtime-sftp-session.test.ts \
src/main/ssh/ssh-relay-runtime-sftp-tree-transfer.test.ts \
src/main/ssh/ssh-relay-compiled-manifest-trust.test.ts \
src/main/ssh/ssh-relay-official-manifest.test.ts \
@@ -843,6 +844,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-sftp-file-destination.test.ts `
src/main/ssh/ssh-relay-runtime-sftp-session.test.ts `
src/main/ssh/ssh-relay-runtime-sftp-tree-transfer.test.ts `
src/main/ssh/ssh-relay-compiled-manifest-trust.test.ts `
src/main/ssh/ssh-relay-official-manifest.test.ts `
@@ -144,6 +144,7 @@ describe('SSH relay runtime artifact workflow', () => {
'ssh-relay-runtime-source-scan.test.ts',
'ssh-relay-runtime-source-stream.test.ts',
'ssh-relay-runtime-sftp-file-destination.test.ts',
'ssh-relay-runtime-sftp-session.test.ts',
'ssh-relay-runtime-sftp-tree-transfer.test.ts',
'ssh-relay-compiled-manifest-trust.test.ts',
'ssh-relay-official-manifest.test.ts',
@@ -9,9 +9,16 @@ 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 SFTP
session/tree orchestration is exact-head green; raw `ssh2` session-adapter audit is next, 2026-07-15,
Codex implementation owner.** `E-M6-SFTP-TREE-TRANSFER-CI-001` closes all six primary native jobs,
Active checkpoint: **Milestone 6 / Work Package 5 bounded runtime transfer — disconnected raw `ssh2`
session adapter is locally green; exact-head native proof is next, 2026-07-15, Codex implementation
owner.** `E-M6-SFTP-SESSION-ADAPTER-LOCAL-001` passes 9/9 purpose, 70/70 adapter+connection,
182 focused cases with one declared skip, 597 broad relay cases with five declared skips, 280/280
release contracts, typecheck, full lint, formatting, max-lines, diff, protected-resolver, and
isolation gates. No live SFTP/tree or product/default claim is made.
`E-M6-SFTP-SESSION-ADAPTER-AUDIT-001` limits the package to optional-signal channel acquisition,
bound raw operations, raw-close callback settlement, and a five-second grace followed by a required
caller-owned connection-force-close hook. No tree importer or live/product claim is included.
`E-M6-SFTP-TREE-TRANSFER-CI-001` closes all six primary native jobs,
both Linux supplements, Windows x64 floor, PR Checks, Golden E2E, and computer-use on exact commit
`28e7a6e5f`; Windows arm64 retains only the declared hosted build-26200 versus required-26100
rejection after complete runtime smoke. No live SFTP/server or product/default claim is made.
@@ -8,8 +8,8 @@ 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 SFTP session/tree orchestration is exact-head green; raw `ssh2` session-adapter audit is next**. 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: **SFTP SESSION/TREE ORCHESTRATION EXACT-HEAD GREEN — 2026-07-15, Codex implementation owner** — `E-M6-SFTP-TREE-TRANSFER-CI-001` closes all-six primary native, both Linux supplement, Windows x64 floor, PR Checks, Golden E2E, and computer-use proof for exact implementation commit `28e7a6e5f`; Windows arm64 retains only the declared hosted build-26200 versus required-26100 floor rejection after complete runtime smoke. Audit the smallest disconnected raw `ssh2` session adapter next. Do not credit a live SFTP server/remote cell, full-size network transfer, high-latency comparison, product/settings/fallback wiring, tuple enablement, publication, or default behavior from the callback abstraction. Merging to `main` remains prohibited, SignPath is deferred, and legacy remains the production default.<br>
Current phase: Milestone 6 / Work Package 5 bounded runtime transfer — **In progress — 2026-07-15, Codex implementation owner: disconnected raw `ssh2` session adapter is locally green; exact-head native proof is next**. 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: **RAW SFTP SESSION ADAPTER LOCAL GREEN — 2026-07-15, Codex implementation owner** — `E-M6-SFTP-SESSION-ADAPTER-LOCAL-001` passes the nine-case purpose contract, optional-signal connection lifecycle, focused/broad/release tests, typecheck, full lint, format, max-lines, diff, protected-resolver, and isolation gates. It proves bound raw operations, raw-close callback settlement, and five-second force-close escalation at a disconnected factory; existing no-argument `SshConnection.sftp()` behavior is preserved. Commit/push and require exact-head all-six native and adjacent CI before any live adapter/tree composition. Do not add a tree-transfer importer, live SFTP/server claim, product/settings/fallback wiring, tuple enablement, publication, or default behavior. 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)
@@ -17044,6 +17044,112 @@ config/scripts/ssh-relay-runtime-workflow.test.mjs` — PASS, 7/7 in 356 ms. The
product/Beta/settings/fallback/tuple/publication/default path remain absent; legacy remains the
sole production/default path and SignPath remains deferred.
### E-M6-SFTP-SESSION-ADAPTER-AUDIT-001 — raw ssh2 channel acquisition and callback settlement
- Date/owner/base: 2026-07-15, Codex implementation owner; exact clean evidence base `d82a8abb1`
after `E-M6-SFTP-TREE-TRANSFER-CI-001` closure.
- Dependency evidence: audited installed `ssh2` 1.17.0. `SFTP.end()` delegates to `destroy()` and
sends channel close. `onCHANNEL_CLOSE` invokes the SFTP channel's `push(null)`; SFTP `push(null)`
runs `cleanupRequests`, invoking every pending request callback with an error, before emitting
`end`; only afterward does the channel emit `close`. Therefore the public raw `close` event is the
minimum safe normal-close boundary for retained callback/source-buffer release and session-slot
settlement. Do not call or depend on private `_requests` or synthesize stream events.
- Acquisition boundary: extend `SshConnection.sftp` with an optional `AbortSignal` only, preserving
all existing no-argument behavior. Pass it through the existing session-limit retry and bounded
callback-open lifecycle so pre-open abort performs no channel request, abort during open awaits a
late channel's close up to the established five-second grace, and timeout still ends a late SFTP
channel. No authentication, connection selection, retry classification, or transport default
changes.
- Adapter input/output: accept the same required caller-owned signal, an `openRawSession(signal)`
factory, and a required `forceCloseConnection(reason)` hook. Return the exact tree-session shape
with explicitly bound raw `mkdir`, `rmdir`, `open`, `write`, `fchmod`, `fstat`, `close`, and
`unlink` callbacks. Do not spread unbound ssh2 methods, copy/source-buffer chunks, expose remote
paths in adapter-authored diagnostics, or acquire any second channel.
- Close/error contract: install a raw error listener before exposing operations. Session close is
idempotent, calls raw `end()` once, and resolves only after raw `close`, which is the audited point
after pending callback cleanup. If close is absent for five seconds, invoke the caller-owned
force-close hook and still require the raw close event before settlement. Preserve raw/end/force
failures and replace the listener with a late-error sink only after close. Reject operations that
begin after closing without invoking raw methods.
- Required purpose RED/green: exact signal forwarding; bound-operation receiver/argument/callback
fidelity; normal end-to-close ordering; a retained write callback settles before session close;
idempotent concurrent close; raw error joined after close; synchronous end failure; five-second
force-close escalation that still awaits raw close; failed force close; post-open cancellation;
pre-open cancellation through `SshConnection.sftp`; mid-open abort with late session end/close;
no later raw operations; path-free adapter diagnostics; and suite inclusion exactly once in both
native workflow families before implementation.
- Explicit residual boundary: the force-close hook remains caller-provided and has no product
implementation in this package. Live OpenSSH server behavior, real connection-force-close and
reconnect effects, full-size network transfer, high-RTT concurrency, `MaxSessions=1`, tree
importer, bundled-Node verification/install/publish/launch, and every product/Beta/settings/
fallback/tuple/publication/default path remain absent. Legacy remains the sole production/default
path and SignPath remains deferred.
### E-M6-SFTP-SESSION-ADAPTER-LOCAL-RED-001 — audited raw ssh2 adapter is absent
- Date/owner/environment: 2026-07-15, Codex implementation owner; local macOS arm64, Node 26.0.0,
pnpm 10.24.0, atop exact evidence base `d82a8abb1` plus the audit/test/workflow-only diff.
- Purpose command: `/usr/bin/time -l pnpm exec vitest run --config config/vitest.config.ts
--maxWorkers=1 src/main/ssh/ssh-relay-runtime-sftp-session.test.ts` — EXPECTED FAIL, exit 1: one
failed suite and zero collected tests because `./ssh-relay-runtime-sftp-session` is absent; 234 ms
Vitest / 1.44 seconds wall and 131,432,448-byte maximum RSS.
- Workflow-oracle command: the same prefix with
`config/scripts/ssh-relay-runtime-workflow.test.mjs` — PASS, 7/7 in 289 ms Vitest / 1.21 seconds
wall and 132,595,712-byte maximum RSS. The purpose suite occurs exactly once in both native
command families before implementation.
- RED scope: the adapter scaffold covers signal forwarding, bound receiver/callback fidelity,
retained callback before close, idempotent close, raw error ordering, five-second force-close
escalation/failure, post-open cancellation, late-operation rejection, and path-free diagnostics.
Two connection lifecycle cases cover pre-open and mid-open SFTP cancellation. The protected
resolver files remain untouched. No adapter implementation, tree importer, live SSH, product/
settings/fallback/tuple/publication/default behavior, or SignPath work exists.
### E-M6-SFTP-SESSION-ADAPTER-LOCAL-001 — disconnected raw ssh2 session lifecycle is locally green
- Date/owner/environment: 2026-07-15, Codex implementation owner; local macOS arm64, Node 26.0.0,
pnpm 10.24.0, atop exact evidence base `d82a8abb1` plus this isolated implementation/checklist
diff. Exact Node 24 remains the native CI gate.
- Implementation: the 208-line `ssh-relay-runtime-sftp-session.ts` accepts only the exact signal,
raw opener, and required connection-force-close hook. It attaches error/close listeners before
exposing a deeply frozen session, binds all eight operations to the raw receiver, preserves raw
callbacks as source-buffer boundaries, rejects late operations, calls raw `end()` exactly once,
waits for audited raw close, escalates after five seconds, still requires raw close after a
successful force hook, and joins raw/end/force failures with path-free adapter diagnostics. The
optional signal added to `SshConnection.sftp` flows through its existing retry/open/late-close
lifecycle; every existing no-argument caller is unchanged.
- Purpose command: `/usr/bin/time -l pnpm exec vitest run --config config/vitest.config.ts
--maxWorkers=1 src/main/ssh/ssh-relay-runtime-sftp-session.test.ts` — PASS, 9/9 in 240 ms Vitest /
1.46 seconds wall, 132,349,952-byte maximum RSS, zero swaps. Cases cover exact signal and bound
receiver fidelity, retained callback before close, concurrent idempotent close, raw-error order,
five-second escalation that still awaits raw close, synchronous raw-end and force-hook failures,
post-open cancellation cleanup, late-operation rejection, and path-free diagnostics.
- Connection lifecycle command: the purpose suite plus `ssh-connection.test.ts` — PASS, 70/70 in
1.19 seconds Vitest / 2.18 seconds wall, 144,179,200-byte maximum RSS. The two new connection
cases prove pre-open abort creates no SFTP request and mid-open abort ends a late session but does
not settle until its close event frees the session slot.
- Focused command: connection, source tree/scan/stream, per-file destination, raw session, tree
transfer, acquisition/cache resolution/population/integration, and workflow-oracle thirteen-file
command — PASS, 182 passed / one declared platform skip in 9.05 seconds Vitest / 10.12 seconds
wall, 167,854,080-byte maximum RSS, zero swaps.
- Broad commands: `src/main/ssh/ssh-relay-*.test.ts` — PASS, 45 files passed / three declared skipped,
597 tests passed / five declared platform/full-size skips in 24.42 seconds Vitest / 25.82 seconds
wall, 249,495,552-byte maximum RSS; `config/scripts/ssh-relay-runtime-*.test.mjs` — PASS, 50 files
/ 280 tests in 17.40 seconds Vitest / 18.76 seconds wall, 195,608,576-byte maximum RSS. Only the
established stale-lock diagnostic and local Node 26 module deprecation appear.
- Static/isolation commands: `pnpm typecheck`, targeted `oxlint`, targeted `oxfmt --check`, full
`pnpm lint`, standalone max-lines ratchet, `git diff --check`, protected-resolver empty diff, and
no non-test importer pass. Full lint takes 19.75 seconds at 2,089,172,992-byte maximum RSS and
passes switch exhaustiveness, 41 reliability gates, 355-entry max-lines ratchet, bundled-skill
verification, 9,837 localization references, locale parity, and zero-candidate coverage; only
pre-existing warnings remain. Module/test sizes are 208/263 lines with no bypass or vague module.
- Boundary/residual gaps: the raw object and force-close hook are mocked. This does not prove live
OpenSSH/SFTP channel behavior, a real connection-force-close implementation, reconnect effects,
full-size network memory/cancellation, server filesystem/modes, high-RTT concurrency,
`MaxSessions=1`, or raw adapter/tree composition. The adapter has no non-test importer. Remote
bundled-Node verification/install/publish/launch and every product/Beta/settings/fallback/tuple/
publication/default path remain absent; legacy remains the sole production/default path and
SignPath remains deferred. Exact-head all-six native and adjacent CI is required next.
## Accepted Gaps
No product gap is accepted merely because it appears in this list. Each entry requires explicit
+30
View File
@@ -900,6 +900,36 @@ describe('SshConnection', () => {
}
})
it('rejects pre-open SFTP cancellation without requesting a channel', async () => {
const conn = new SshConnection(createTarget(), createCallbacks())
await conn.connect()
sftpBehavior = 'pending'
const controller = new AbortController()
controller.abort()
await expect(conn.sftp(controller.signal)).rejects.toMatchObject({ name: 'AbortError' })
expect(pendingSftpCallback).toBeNull()
})
it('ends and awaits a late SFTP channel after mid-open cancellation', async () => {
const conn = new SshConnection(createTarget(), createCallbacks())
await conn.connect()
sftpBehavior = 'pending'
const controller = new AbortController()
const lateSftp = Object.assign(new EventEmitter(), { end: vi.fn() })
const outcome = conn
.sftp(controller.signal)
.then(() => 'opened')
.catch((error: Error) => error.name)
controller.abort()
pendingSftpCallback?.(undefined, lateSftp)
expect(await Promise.race([outcome, Promise.resolve('pending')])).toBe('pending')
expect(lateSftp.end).toHaveBeenCalledOnce()
lateSftp.emit('close')
await expect(outcome).resolves.toBe('AbortError')
})
it('uses system SSH transport when ProxyUseFdpass is resolved by OpenSSH', async () => {
vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig())
const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks())
+10 -7
View File
@@ -161,7 +161,7 @@ export class SshConnection {
)
}
async sftp(): Promise<SFTPWrapper> {
async sftp(signal?: AbortSignal): Promise<SFTPWrapper> {
if (this.useSystemSshTransport) {
throw new Error('SFTP is not available when using system SSH transport')
}
@@ -169,12 +169,15 @@ export class SshConnection {
throw new Error('Not connected')
}
const client = this.client
return this.openSessionChannelWithRetry(() =>
this.waitForSshCallback(
'SSH SFTP channel timed out',
(callback) => client.sftp(callback),
(sftp) => sftp.end()
)
return this.openSessionChannelWithRetry(
() =>
this.waitForSshCallback(
'SSH SFTP channel timed out',
(callback) => client.sftp(callback),
(sftp) => sftp.end(),
signal
),
signal
)
}
@@ -0,0 +1,263 @@
import { EventEmitter } from 'node:events'
import type { SFTPWrapper } from 'ssh2'
import { describe, expect, it, vi } from 'vitest'
import { openSshRelayRuntimeSftpTreeSession } from './ssh-relay-runtime-sftp-session'
type Callback = (error?: Error) => void
function createRawSession() {
const emitter = new EventEmitter()
const raw = Object.assign(emitter, {
mkdir: vi.fn(function (this: unknown, _path: string, _attributes: unknown, callback: Callback) {
callback()
}),
rmdir: vi.fn(function (this: unknown, _path: string, callback: Callback) {
callback()
}),
open: vi.fn(function (
this: unknown,
_path: string,
_flags: string,
_attributes: unknown,
callback: (error: Error | undefined, handle: Buffer) => void
) {
callback(undefined, Buffer.from('handle'))
}),
write: vi.fn(function (
this: unknown,
_handle: Buffer,
_buffer: Buffer,
_offset: number,
_length: number,
_position: number,
callback: Callback
) {
callback()
}),
fchmod: vi.fn(function (this: unknown, _handle: Buffer, _mode: number, callback: Callback) {
callback()
}),
fstat: vi.fn(function (
this: unknown,
_handle: Buffer,
callback: (error: Error | undefined, value: { mode: number }) => void
) {
callback(undefined, { mode: 0o100755 })
}),
close: vi.fn(function (this: unknown, _handle: Buffer, callback: Callback) {
callback()
}),
unlink: vi.fn(function (this: unknown, _path: string, callback: Callback) {
callback()
}),
end: vi.fn()
}) as unknown as SFTPWrapper
return raw
}
function openSession(
raw: SFTPWrapper,
options: {
signal?: AbortSignal
forceCloseConnection?: (reason: unknown) => Promise<void>
} = {}
) {
const signal = options.signal ?? new AbortController().signal
const openRawSession = vi.fn(async (receivedSignal: AbortSignal) => {
expect(receivedSignal).toBe(signal)
return raw
})
const forceCloseConnection = vi.fn(options.forceCloseConnection ?? (async () => {}))
return {
opened: openSshRelayRuntimeSftpTreeSession({
signal,
openRawSession,
forceCloseConnection
}),
openRawSession,
forceCloseConnection
}
}
describe('SSH relay runtime raw SFTP session adapter', () => {
it('forwards the exact signal and binds every operation to the raw session', async () => {
const raw = createRawSession()
const { opened, openRawSession } = openSession(raw)
const session = await opened
const callback = vi.fn()
session.operations.mkdir('/stage', { mode: 0o700 }, callback)
session.operations.rmdir('/stage', callback)
session.operations.open('/stage/node', 'wx', { mode: 0o755 }, callback)
session.operations.write(Buffer.from('h'), Buffer.from('x'), 0, 1, 0, callback)
session.operations.fchmod(Buffer.from('h'), 0o755, callback)
session.operations.fstat(Buffer.from('h'), callback)
session.operations.close(Buffer.from('h'), callback)
session.operations.unlink('/stage/node', callback)
expect(openRawSession).toHaveBeenCalledOnce()
for (const operation of [
raw.mkdir,
raw.rmdir,
raw.open,
raw.write,
raw.fchmod,
raw.fstat,
raw.close,
raw.unlink
]) {
expect(operation).toHaveBeenCalledOnce()
expect(vi.mocked(operation).mock.instances[0]).toBe(raw)
}
})
it('awaits raw close after retained callbacks settle', async () => {
const raw = createRawSession()
let retainedCallback: Callback | undefined
vi.mocked(raw.write).mockImplementationOnce(
(_handle, _buffer, _offset, _length, _position, callback) => {
retainedCallback = callback
}
)
const { opened, forceCloseConnection } = openSession(raw)
const session = await opened
let writeSettled = false
session.operations.write(Buffer.from('h'), Buffer.from('x'), 0, 1, 0, () => {
writeSettled = true
})
let closeSettled = false
const close = session.close().then(() => {
closeSettled = true
})
await Promise.resolve()
expect(raw.end).toHaveBeenCalledOnce()
expect(closeSettled).toBe(false)
retainedCallback?.(new Error('session closed'))
raw.emit('close')
await close
expect(writeSettled).toBe(true)
expect(forceCloseConnection).not.toHaveBeenCalled()
})
it('makes concurrent close idempotent', async () => {
const raw = createRawSession()
const { opened } = openSession(raw)
const session = await opened
const first = session.close()
const second = session.close(new Error('later reason'))
expect(first).toBe(second)
raw.emit('close')
await first
expect(raw.end).toHaveBeenCalledOnce()
})
it('waits for close before rejecting a raw session error', async () => {
const raw = createRawSession()
const { opened } = openSession(raw)
const session = await opened
const close = session.close()
raw.emit('error', new Error('raw session failed'))
const early = await Promise.race([
close.then(
() => 'settled',
() => 'settled'
),
Promise.resolve('pending')
])
expect(early).toBe('pending')
raw.emit('close')
await expect(close).rejects.toThrow('raw session failed')
})
it('forces the owning connection after the five-second close grace', async () => {
vi.useFakeTimers()
try {
const raw = createRawSession()
const forceCloseConnection = vi.fn(async () => {})
const { opened } = openSession(raw, { forceCloseConnection })
const session = await opened
const reason = new Error('cancelled transfer')
const close = session.close(reason)
await vi.advanceTimersByTimeAsync(4_999)
expect(forceCloseConnection).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(await Promise.race([close.then(() => 'settled'), Promise.resolve('pending')])).toBe(
'pending'
)
raw.emit('close')
await close
expect(forceCloseConnection).toHaveBeenCalledWith(reason)
} finally {
vi.useRealTimers()
}
})
it('force-closes and reports a synchronous raw end failure', async () => {
const raw = createRawSession()
vi.mocked(raw.end).mockImplementationOnce(() => {
throw new Error('raw end failed')
})
const forceCloseConnection = vi.fn(async () => {
raw.emit('close')
})
const { opened } = openSession(raw, { forceCloseConnection })
const session = await opened
await expect(session.close()).rejects.toThrow('raw end failed')
expect(forceCloseConnection).toHaveBeenCalledOnce()
})
it('reports force-close failure and does not invent raw close', async () => {
vi.useFakeTimers()
try {
const raw = createRawSession()
const { opened } = openSession(raw, {
forceCloseConnection: async () => {
throw new Error('force close failed')
}
})
const session = await opened
const close = session.close(new Error('primary'))
const rejected = expect(close).rejects.toThrow('force close failed')
await vi.advanceTimersByTimeAsync(5_000)
await rejected
} finally {
vi.useRealTimers()
}
})
it('closes a raw session returned after cancellation and rejects opening', async () => {
const raw = createRawSession()
const controller = new AbortController()
const { opened } = openSession(raw, { signal: controller.signal })
controller.abort(new Error('cancelled after raw open'))
await Promise.resolve()
raw.emit('close')
await expect(opened).rejects.toThrow('cancelled after raw open')
expect(raw.end).toHaveBeenCalledOnce()
})
it('rejects late operations without invoking the raw session or exposing paths', async () => {
const raw = createRawSession()
const { opened } = openSession(raw)
const session = await opened
const close = session.close()
raw.emit('close')
await close
const callback = vi.fn()
const secret = '/home/private-user/stage/node'
session.operations.unlink(secret, callback)
expect(raw.unlink).not.toHaveBeenCalled()
expect(callback).toHaveBeenCalledWith(expect.any(Error))
expect(callback.mock.calls[0]?.[0]?.message).not.toContain(secret)
})
})
@@ -0,0 +1,208 @@
import type { SFTPWrapper } from 'ssh2'
import type {
SshRelayRuntimeSftpTreeOperations,
SshRelayRuntimeSftpTreeSession
} from './ssh-relay-runtime-sftp-tree-transfer'
const SESSION_CLOSE_GRACE_MS = 5_000
export type OpenSshRelayRuntimeSftpTreeSessionOptions = Readonly<{
signal: AbortSignal
openRawSession: (signal: AbortSignal) => Promise<SFTPWrapper>
// Why: a peer that ignores channel close must not retain source buffers indefinitely.
forceCloseConnection: (reason: unknown) => Promise<void>
}>
type SessionState = 'open' | 'closing' | 'closed'
function closedOperationError(): Error {
return new Error('SSH relay runtime SFTP session is closing or closed')
}
function uniqueFailures(failures: readonly unknown[]): unknown[] {
return failures.filter((failure, index) => failures.indexOf(failure) === index)
}
function cleanupFailure(failures: readonly unknown[]): unknown | undefined {
const unique = uniqueFailures(failures)
if (unique.length === 0) {
return undefined
}
return unique.length === 1
? unique[0]
: new AggregateError(unique, 'SSH relay runtime SFTP session close failed')
}
function createBoundOperations(
raw: SFTPWrapper,
getState: () => SessionState
): SshRelayRuntimeSftpTreeOperations {
const isClosed = (): boolean => getState() !== 'open'
return Object.freeze({
mkdir: (path, attributes, callback) => {
if (isClosed()) {
callback(closedOperationError())
return
}
raw.mkdir(path, attributes, (error) => callback(error ?? undefined))
},
rmdir: (path, callback) => {
if (isClosed()) {
callback(closedOperationError())
return
}
raw.rmdir(path, (error) => callback(error ?? undefined))
},
open: (path, flags, attributes, callback) => {
if (isClosed()) {
callback(closedOperationError(), Buffer.alloc(0))
return
}
raw.open(path, flags, attributes, (error, handle) => callback(error ?? undefined, handle))
},
write: (handle, buffer, offset, length, position, callback) => {
if (isClosed()) {
callback(closedOperationError())
return
}
// Why: only the raw callback releases the source stream's borrowed chunk view.
raw.write(handle, buffer, offset, length, position, (error) => callback(error ?? undefined))
},
fchmod: (handle, mode, callback) => {
if (isClosed()) {
callback(closedOperationError())
return
}
raw.fchmod(handle, mode, (error) => callback(error ?? undefined))
},
fstat: (handle, callback) => {
if (isClosed()) {
callback(closedOperationError(), { mode: 0 } as never)
return
}
raw.fstat(handle, (error, attributes) => callback(error ?? undefined, attributes))
},
close: (handle, callback) => {
if (isClosed()) {
callback(closedOperationError())
return
}
raw.close(handle, (error) => callback(error ?? undefined))
},
unlink: (path, callback) => {
if (isClosed()) {
callback(closedOperationError())
return
}
raw.unlink(path, (error) => callback(error ?? undefined))
}
})
}
function createSession(
raw: SFTPWrapper,
forceCloseConnection: (reason: unknown) => Promise<void>
): SshRelayRuntimeSftpTreeSession {
let state: SessionState = 'open'
let rawError: unknown
let closePromise: Promise<void> | undefined
let resolveRawClose: (() => void) | undefined
const rawClose = new Promise<void>((resolve) => {
resolveRawClose = resolve
})
const onRawClose = (): void => resolveRawClose?.()
const onRawError = (error: unknown): void => {
rawError ??= error
}
const swallowLateRawError = (): void => {}
raw.once('close', onRawClose)
raw.on('error', onRawError)
const finishListeners = (): void => {
raw.removeListener('error', onRawError)
// Why: ssh2 can emit a final socket error after channel close; no owner remains to observe it.
raw.on('error', swallowLateRawError)
}
const waitForCloseGrace = async (): Promise<boolean> => {
let timer: ReturnType<typeof setTimeout> | undefined
const grace = new Promise<false>((resolve) => {
timer = setTimeout(() => resolve(false), SESSION_CLOSE_GRACE_MS)
})
const closed = await Promise.race([rawClose.then(() => true as const), grace])
clearTimeout(timer)
return closed
}
const performClose = async (reason?: unknown): Promise<void> => {
state = 'closing'
const failures: unknown[] = []
let ended = false
try {
raw.end()
ended = true
} catch (error) {
failures.push(error)
}
if (!ended || !(await waitForCloseGrace())) {
const forceReason = reason ?? failures[0] ?? rawError ?? closedOperationError()
try {
await forceCloseConnection(forceReason)
} catch (error) {
failures.push(error)
state = 'closed'
finishListeners()
throw cleanupFailure(failures)
}
// Why: force-close completion alone is insufficient; raw close proves request callbacks ran.
await rawClose
}
state = 'closed'
finishListeners()
if (rawError !== undefined) {
failures.push(rawError)
}
const failure = cleanupFailure(failures)
if (failure !== undefined) {
throw failure
}
}
const close = (reason?: unknown): Promise<void> => {
closePromise ??= performClose(reason)
return closePromise
}
const operations = createBoundOperations(raw, () => state)
return Object.freeze({ operations, close })
}
export async function openSshRelayRuntimeSftpTreeSession(
options: OpenSshRelayRuntimeSftpTreeSessionOptions
): Promise<SshRelayRuntimeSftpTreeSession> {
if (
!options ||
typeof options.openRawSession !== 'function' ||
typeof options.forceCloseConnection !== 'function'
) {
throw new Error('SSH relay runtime SFTP session adapter input is invalid')
}
options.signal.throwIfAborted()
const raw = await options.openRawSession(options.signal)
const session = createSession(raw, options.forceCloseConnection)
try {
options.signal.throwIfAborted()
return session
} catch (error) {
try {
await session.close(error)
} catch (cleanupError) {
throw new AggregateError(
[error, cleanupError],
'SSH relay runtime SFTP session open cancellation cleanup failed'
)
}
throw error
}
}