diff --git a/.github/workflows/ssh-relay-runtime-artifacts.yml b/.github/workflows/ssh-relay-runtime-artifacts.yml
index 3180016e962..60aaea0a288 100644
--- a/.github/workflows/ssh-relay-runtime-artifacts.yml
+++ b/.github/workflows/ssh-relay-runtime-artifacts.yml
@@ -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 `
diff --git a/config/scripts/ssh-relay-runtime-workflow.test.mjs b/config/scripts/ssh-relay-runtime-workflow.test.mjs
index 65ab35c6183..990f4ae5fa7 100644
--- a/config/scripts/ssh-relay-runtime-workflow.test.mjs
+++ b/config/scripts/ssh-relay-runtime-workflow.test.mjs
@@ -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',
diff --git a/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist-summary.md b/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist-summary.md
index 749199b4703..ed2fb860795 100644
--- a/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist-summary.md
+++ b/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist-summary.md
@@ -9,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.
diff --git a/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist.md b/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist.md
index 8778b096c4a..9d7fee9da64 100644
--- a/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist.md
+++ b/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist.md
@@ -8,8 +8,8 @@ work; keep exact commands, runner identities, hashes, metrics, and residual gaps
Date created: 2026-07-14
Last updated: 2026-07-15
-Current phase: Milestone 6 / Work Package 5 bounded runtime transfer — **In progress — 2026-07-15, Codex implementation owner: disconnected 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.
-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.
+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.
+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.
Primary design: [SSH relay GitHub Release plan](./2026-07-14-ssh-relay-github-release-plan.html)
Motivating issues: [#8450](https://github.com/stablyai/orca/issues/8450), [#1693](https://github.com/stablyai/orca/issues/1693)
@@ -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
diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts
index 005d43c9ca8..90807615000 100644
--- a/src/main/ssh/ssh-connection.test.ts
+++ b/src/main/ssh/ssh-connection.test.ts
@@ -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())
diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts
index af74bdc712a..a1e55027080 100644
--- a/src/main/ssh/ssh-connection.ts
+++ b/src/main/ssh/ssh-connection.ts
@@ -161,7 +161,7 @@ export class SshConnection {
)
}
- async sftp(): Promise {
+ async sftp(signal?: AbortSignal): Promise {
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
)
}
diff --git a/src/main/ssh/ssh-relay-runtime-sftp-session.test.ts b/src/main/ssh/ssh-relay-runtime-sftp-session.test.ts
new file mode 100644
index 00000000000..c2db8d003c4
--- /dev/null
+++ b/src/main/ssh/ssh-relay-runtime-sftp-session.test.ts
@@ -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
+ } = {}
+) {
+ 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)
+ })
+})
diff --git a/src/main/ssh/ssh-relay-runtime-sftp-session.ts b/src/main/ssh/ssh-relay-runtime-sftp-session.ts
new file mode 100644
index 00000000000..1d245fcabe5
--- /dev/null
+++ b/src/main/ssh/ssh-relay-runtime-sftp-session.ts
@@ -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
+ // Why: a peer that ignores channel close must not retain source buffers indefinitely.
+ forceCloseConnection: (reason: unknown) => Promise
+}>
+
+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
+): SshRelayRuntimeSftpTreeSession {
+ let state: SessionState = 'open'
+ let rawError: unknown
+ let closePromise: Promise | undefined
+ let resolveRawClose: (() => void) | undefined
+ const rawClose = new Promise((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 => {
+ let timer: ReturnType | undefined
+ const grace = new Promise((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 => {
+ 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 => {
+ closePromise ??= performClose(reason)
+ return closePromise
+ }
+ const operations = createBoundOperations(raw, () => state)
+ return Object.freeze({ operations, close })
+}
+
+export async function openSshRelayRuntimeSftpTreeSession(
+ options: OpenSshRelayRuntimeSftpTreeSessionOptions
+): Promise {
+ 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
+ }
+}