mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 00:03:59 +00:00
feat: add bounded SSH relay SFTP sessions
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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',
|
||||
|
||||
+10
-3
@@ -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.
|
||||
|
||||
+108
-2
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user