From fbec34cf128c3800738d48264ab8b471148b7d57 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 16 Jul 2026 11:14:50 -0400 Subject: [PATCH] feat: add bounded SSH relay SFTP sessions Co-authored-by: Orca --- .../workflows/ssh-relay-runtime-artifacts.yml | 2 + .../ssh-relay-runtime-workflow.test.mjs | 1 + ...elease-implementation-checklist-summary.md | 13 +- ...github-release-implementation-checklist.md | 110 +++++++- src/main/ssh/ssh-connection.test.ts | 30 ++ src/main/ssh/ssh-connection.ts | 17 +- .../ssh-relay-runtime-sftp-session.test.ts | 263 ++++++++++++++++++ .../ssh/ssh-relay-runtime-sftp-session.ts | 208 ++++++++++++++ 8 files changed, 632 insertions(+), 12 deletions(-) create mode 100644 src/main/ssh/ssh-relay-runtime-sftp-session.test.ts create mode 100644 src/main/ssh/ssh-relay-runtime-sftp-session.ts 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 + } +}