/** * SSH session core: exec / upload / download primitives. * * Wraps the `ssh2` client with the security policy decided in Phase 2: * - DNS-pinned socket (preflightAndConnect) — defeats DNS rebinding * - algorithm allowlist (buildAlgorithmsOption) * - closure-capture hostVerifier — sync, no DB writes inside the callback; * verdict is read after the handshake fails and persistent writes happen * in the outer try/catch * - O_NOFOLLOW on the local file for upload/download (workspace validation * plus an extra symlink defense at open time) * - O_CREAT|O_EXCL partial file for download (no race with concurrent * writers; atomic rename to the target on success) * - JSON-envelope output wrapping (wrapOutput) with byte cap + redaction * - hooks for the two non-fatal host-key outcomes (first_observe / mismatch); * the caller (tools/ssh.ts) wires these to setHostKeyPendingWithToken + * audit. Keeping the writes outside the session module lets us unit-test * this file without a DB. * * Plan: docs/superpowers/plans/2026-05-12-ssh-tool-integration.md (Phase 3). */ import { Client, type ConnectConfig } from 'ssh2'; import type * as net from 'node:net'; import { promises as fs, constants as fsConstants, createReadStream, createWriteStream } from 'node:fs'; import * as path from 'node:path'; import { createHash, randomBytes } from 'node:crypto'; import { buildAlgorithmsOption, isAllowedHostKeyType } from './algorithms.js'; import { preflightAndConnect, SshSsrfError, type PreflightResult } from './ssrf.js'; import { parseHostKeyType } from './connection-repo.js'; import { wrapOutput } from './output.js'; import { sanitizeError, clearBuffer } from './crypto.js'; import { logger } from '../logger.js'; export interface ResolvedConnection { id: string; ownerId: string | null; host: string; port: number; username: string; /** Decrypted PEM. Session zeros this buffer in finally. */ privateKeyPem: Buffer; passphrase?: Buffer; /** OpenSSH wire-format host key, base64. null = no key recorded yet (TOFU first contact). */ hostKeyB64: string | null; /** True iff the user has clicked Verify on the recorded key. */ hostKeyVerified: boolean; allowPrivate: boolean; } export type SessionErrorCode = | 'host_key_not_verified' | 'host_key_first_observe' | 'host_key_mismatch' | 'host_key_alg_not_allowed' | 'invalid_host' | 'forbidden_address' | 'dns_failed' | 'connect_failed' | 'connect_timeout' | 'auth_failed' | 'exec_failed' | 'exec_timeout' | 'transfer_timeout' | 'output_too_large' | 'local_io_failed' | 'remote_io_failed' | 'remote_too_large' | 'local_target_exists'; export interface HostKeyObservation { connectionId: string; /** OpenSSH wire-format key, base64. */ b64: string; /** SHA256:base64 fingerprint, OpenSSH style. */ fingerprint: string; } export interface SessionHooks { onFirstObserve: (obs: HostKeyObservation) => Promise<{ token: string } | null>; onMismatch: (obs: HostKeyObservation) => Promise<{ token: string } | null>; } export class SshSessionError extends Error { readonly code: SessionErrorCode; /** OpenSSH-style fingerprint of the host key observed during the failed handshake, if any. */ readonly observedFingerprint?: string; /** Token issued by setHostKeyPendingWithToken for the verify flow, if any. */ readonly pendingToken?: string; constructor(code: SessionErrorCode, message: string, extra: { fingerprint?: string; token?: string } = {}) { super(message); this.code = code; this.name = 'SshSessionError'; if (extra.fingerprint) this.observedFingerprint = extra.fingerprint; if (extra.token) this.pendingToken = extra.token; } } export interface ExecArgs { connection: ResolvedConnection; command: string; /** Optional env to set on the remote shell. Server-side AcceptEnv must allow it. */ env?: Record; /** Wall-clock cap for the exec phase (after handshake). */ timeoutMs: number; /** Per-stream byte cap before truncation; default 32 KiB. */ maxOutputBytes?: number; } export interface ExecResult { /** JSON envelope from wrapOutput — hand directly to LLM. */ outputJson: string; exitCode: number; durationMs: number; /** SHA256:base64 fingerprint of the host key. */ hostFingerprint: string; } export interface UploadArgs { connection: ResolvedConnection; localPath: string; remotePath: string; timeoutMs: number; /** Hard size cap (bytes). */ maxBytes: number; } export interface DownloadArgs { connection: ResolvedConnection; remotePath: string; localPath: string; timeoutMs: number; /** Hard size cap (bytes). */ maxBytes: number; } export interface TransferResult { bytes: number; durationMs: number; hostFingerprint: string; } interface VerifierState { observedKey: Buffer | null; verdict: 'pass' | 'first_observe' | 'mismatch' | 'alg_not_allowed'; } /** OpenSSH-style sha256 fingerprint: 'SHA256:' + base64(sha256(raw)) without '=' padding. */ function sha256Fingerprint(raw: Buffer): string { return 'SHA256:' + createHash('sha256').update(raw).digest('base64').replace(/=+$/, ''); } function newVerifierState(): VerifierState { return { observedKey: null, verdict: 'pass' }; } /** Translate a SshSsrfError into the session-level error code. */ function mapSsrfError(e: SshSsrfError): SshSessionError { switch (e.code) { case 'invalid_host': return new SshSessionError('invalid_host', e.message); case 'forbidden_address': return new SshSessionError('forbidden_address', e.message); case 'dns_failed': return new SshSessionError('dns_failed', e.message); case 'connect_timeout': return new SshSessionError('connect_timeout', e.message); default: return new SshSessionError('connect_failed', e.message); } } /** Open an ssh2 Client with the pre-connected socket; capture host-key verdict in `vstate`. */ async function openClient( connection: ResolvedConnection, preflight: PreflightResult, vstate: VerifierState, readyTimeoutMs: number, ): Promise { const client = new Client(); const config: ConnectConfig = { sock: preflight.socket, algorithms: buildAlgorithmsOption(), privateKey: connection.privateKeyPem, passphrase: connection.passphrase, readyTimeout: readyTimeoutMs, username: connection.username, // ssh2 protocol-level debug (no secrets — only DEBUG/INFO message metadata // like auth method names, kex algorithms, etc.). Routed through our logger // so it only appears when LOG_LEVEL=debug is set. debug: (msg: string) => logger.debug(`[ssh:session:debug] conn=${connection.id} ${msg}`), // Sync verifier — never do async work or DB writes here. hostVerifier: (raw: Buffer): boolean => { vstate.observedKey = Buffer.from(raw); // Algorithm allowlist on the *server's* host key. Reject without // recording anything so an attacker can't poison TOFU with rsa-sha1 // or ssh-dss. const algName = parseHostKeyType(vstate.observedKey.toString('base64')); if (!algName || !isAllowedHostKeyType(algName)) { vstate.verdict = 'alg_not_allowed'; return false; } if (!connection.hostKeyB64) { vstate.verdict = 'first_observe'; return false; } if (vstate.observedKey.toString('base64') !== connection.hostKeyB64) { vstate.verdict = 'mismatch'; return false; } vstate.verdict = 'pass'; return true; }, }; return new Promise((resolve, reject) => { let settled = false; const settle = (err: Error | null) => { if (settled) return; settled = true; // Detach the handshake-only listeners with the exact references // (removeAllListeners would also strip the permanent error handler). client.removeListener('ready', onReady); client.removeListener('close', onClose); if (err) { // Failed handshake: the caller destroys the socket and never uses // this client, so drop the error handler too. client.removeListener('error', onError); reject(err); } else { // Success: keep `onError` attached. The client can outlive this // call (an interactive console keeps it alive for the whole // session), and ssh2 re-emits transport-level failures // (ECONNRESET on idle timeout, network drop) as an 'error' event // on the Client. A Node 'error' event with no listener is fatal to // the entire process — this permanent handler is the safety net // that turns a dropped connection into a log line instead of a // crash. The live-client owner (ConsoleSession / exec finally) // drives the actual teardown. resolve(client); } }; const onReady = () => settle(null); const onClose = () => settle(new Error('connection_closed_during_handshake')); const onError = (err: Error) => { if (!settled) { settle(err); } else { logger.warn( `[ssh:session] client error after handshake conn=${connection.id}: ${sanitizeError(err).message}`, ); } }; client.on('error', onError); client.once('ready', onReady); client.once('close', onClose); try { client.connect(config); } catch (e) { settle(e as Error); } }); } /** * Map a handshake/connect error and the closure verifier verdict into a * SshSessionError, and call the right repo hook on the way. * This runs AFTER ssh2 rejects the connection — i.e. outside the synchronous * verifier callback, where async DB writes are safe. */ async function handleConnectFailure( connection: ResolvedConnection, vstate: VerifierState, rawErr: Error, hooks: SessionHooks, ): Promise { if (vstate.verdict === 'alg_not_allowed' && vstate.observedKey) { return new SshSessionError( 'host_key_alg_not_allowed', 'Server host key uses an algorithm not in the allowlist', { fingerprint: sha256Fingerprint(vstate.observedKey) }, ); } if (vstate.verdict === 'first_observe' && vstate.observedKey) { const fp = sha256Fingerprint(vstate.observedKey); const b64 = vstate.observedKey.toString('base64'); const r = await hooks.onFirstObserve({ connectionId: connection.id, b64, fingerprint: fp }); return new SshSessionError( 'host_key_first_observe', 'Host key observed for the first time; user must verify before connecting', { fingerprint: fp, token: r?.token }, ); } if (vstate.verdict === 'mismatch' && vstate.observedKey) { const fp = sha256Fingerprint(vstate.observedKey); const b64 = vstate.observedKey.toString('base64'); const r = await hooks.onMismatch({ connectionId: connection.id, b64, fingerprint: fp }); return new SshSessionError( 'host_key_mismatch', 'Host key fingerprint does not match the recorded key', { fingerprint: fp, token: r?.token }, ); } // Non-host-key failure. Map common ssh2 phrasings. const msg = sanitizeError(rawErr).message; if (/authentication/i.test(msg) && /fail/i.test(msg)) { return new SshSessionError('auth_failed', msg); } if (/timed?\s?out|readytimeout/i.test(msg)) { return new SshSessionError('connect_timeout', msg); } return new SshSessionError('connect_failed', msg); } /** Common preflight + open client. Returns either a connected Client or throws SshSessionError. */ async function connect(connection: ResolvedConnection, hooks: SessionHooks, timeoutMs: number) { if (!connection.hostKeyVerified) { throw new SshSessionError( 'host_key_not_verified', 'Connection has no verified host key; complete the TOFU flow first', ); } let preflight: PreflightResult; try { preflight = await preflightAndConnect({ host: connection.host, port: connection.port, allowPrivate: connection.allowPrivate, timeoutMs, }); } catch (e) { if (e instanceof SshSsrfError) throw mapSsrfError(e); throw new SshSessionError('connect_failed', (e as Error).message); } const vstate = newVerifierState(); try { const client = await openClient(connection, preflight, vstate, timeoutMs); return { client, fingerprint: sha256Fingerprint(vstate.observedKey!) }; } catch (e) { // openClient rejected — close the pre-connected socket so it doesn't leak. try { preflight.socket.destroy(); } catch { /* ignore */ } throw await handleConnectFailure(connection, vstate, e as Error, hooks); } } /** Execute a command on the connected client; returns wrapped JSON output. */ /** * Test the connection without running a command — used by Phase 5 * `POST /api/ssh/connections/:id/test`. * * Differs from sshExec in two ways: * - Skips the `hostKeyVerified` precondition (callers test exactly when the * key is unknown). * - Treats first_observe / mismatch / alg_not_allowed as RESULTS, not errors. * Real network/auth/timeout failures still throw SshSessionError. * * Auth still runs when the host key matches a previously-recorded one — that * gives the test endpoint useful "key + cred + host" coverage. For a new * connection (hostKeyB64 = null) the verifier rejects before auth, so the * remote logs no auth attempt. */ export interface TestArgs { connection: ResolvedConnection; timeoutMs: number; } export type TestVerdict = 'pass' | 'first_observe' | 'mismatch' | 'alg_not_allowed'; export interface TestResult { verdict: TestVerdict; fingerprint: string; hostKeyB64: string; hostKeyType: string; } export async function sshTest(args: TestArgs): Promise { // Bypass hostKeyVerified precondition; the verifier captures the actual // observation in vstate regardless. const conn: ResolvedConnection = { ...args.connection, hostKeyVerified: true }; let preflight: PreflightResult; try { preflight = await preflightAndConnect({ host: conn.host, port: conn.port, allowPrivate: conn.allowPrivate, timeoutMs: args.timeoutMs, }); } catch (e) { if (e instanceof SshSsrfError) throw mapSsrfError(e); throw new SshSessionError('connect_failed', (e as Error).message); } const vstate = newVerifierState(); let client: Client | null = null; try { client = await openClient(conn, preflight, vstate, args.timeoutMs); const observed = vstate.observedKey; if (!observed) throw new SshSessionError('connect_failed', 'no host key observed'); const b64 = observed.toString('base64'); return { verdict: 'pass', fingerprint: sha256Fingerprint(observed), hostKeyB64: b64, hostKeyType: parseHostKeyType(b64) ?? 'unknown', }; } catch (e) { try { preflight.socket.destroy(); } catch { /* ignore */ } if (vstate.observedKey && vstate.verdict !== 'pass') { const b64 = vstate.observedKey.toString('base64'); return { verdict: vstate.verdict, fingerprint: sha256Fingerprint(vstate.observedKey), hostKeyB64: b64, hostKeyType: parseHostKeyType(b64) ?? 'unknown', }; } // Genuine failure with no host-key observation. const msg = sanitizeError(e as Error).message; if (/authentication/i.test(msg) && /fail/i.test(msg)) { throw new SshSessionError('auth_failed', msg); } if (/timed?\s?out|readytimeout/i.test(msg)) { throw new SshSessionError('connect_timeout', msg); } throw new SshSessionError('connect_failed', msg); } finally { if (client) { try { client.end(); } catch { /* ignore */ } } } } export async function sshExec(args: ExecArgs, hooks: SessionHooks): Promise { const started = Date.now(); const { client, fingerprint } = await connect(args.connection, hooks, args.timeoutMs); try { return await runExec(client, args, fingerprint, started); } finally { try { client.end(); } catch { /* ignore */ } clearBuffer(args.connection.privateKeyPem); clearBuffer(args.connection.passphrase); } } function runExec( client: Client, args: ExecArgs, hostFingerprint: string, started: number, ): Promise { return new Promise((resolve, reject) => { const cap = args.maxOutputBytes ?? 32 * 1024; // We hold up to cap*2 bytes per stream in memory and stop buffering past // that. The raw byte totals (sout/serr) keep growing so the JSON envelope // can report the true pre-cap size. const chunks = { stdout: [] as Buffer[], stderr: [] as Buffer[], outBuf: 0, errBuf: 0, sout: 0, serr: 0, }; let exitCode = -1; let settled = false; const settle = (err: Error | null, result?: ExecResult) => { if (settled) return; settled = true; clearTimeout(timer); if (err) reject(err); else if (result) resolve(result); }; const timer = setTimeout(() => { try { client.end(); } catch { /* ignore */ } settle(new SshSessionError('exec_timeout', `Exec exceeded ${args.timeoutMs} ms`)); }, args.timeoutMs); const opts = args.env ? { env: args.env as NodeJS.ProcessEnv } : undefined; const cb = (err: Error | undefined, stream: import('ssh2').ClientChannel | undefined) => { if (err || !stream) { return settle(new SshSessionError('exec_failed', sanitizeError(err ?? new Error('no stream')).message)); } const ceil = cap * 2; stream.on('data', (d: Buffer) => { chunks.sout += d.length; if (chunks.outBuf < ceil) { chunks.stdout.push(d); chunks.outBuf += d.length; } }); stream.stderr.on('data', (d: Buffer) => { chunks.serr += d.length; if (chunks.errBuf < ceil) { chunks.stderr.push(d); chunks.errBuf += d.length; } }); stream.on('exit', (code: number | null) => { exitCode = typeof code === 'number' ? code : -1; }); stream.on('close', () => { const outputJson = wrapOutput({ stdout: Buffer.concat(chunks.stdout, chunks.outBuf), stderr: Buffer.concat(chunks.stderr, chunks.errBuf), exitCode, durationMs: Date.now() - started, capBytes: cap, stdoutBytesRaw: chunks.sout, stderrBytesRaw: chunks.serr, }); settle(null, { outputJson, exitCode, durationMs: Date.now() - started, hostFingerprint, }); }); stream.on('error', (e: Error) => settle(new SshSessionError('exec_failed', sanitizeError(e).message)), ); }; // ssh2 has both 2- and 3-arg overloads of exec. if (opts) client.exec(args.command, opts, cb); else client.exec(args.command, cb); }); } /** Wrap a node-style sftp method into a Promise. */ function promisify(fn: (cb: (err: Error | undefined, value: T) => void) => void): Promise { return new Promise((resolve, reject) => { fn((err, v) => (err ? reject(err) : resolve(v))); }); } async function withSftp( client: Client, body: (sftp: import('ssh2').SFTPWrapper) => Promise, ): Promise { const sftp = await promisify((cb) => client.sftp((err, s) => cb(err ?? undefined, s as import('ssh2').SFTPWrapper)), ); try { return await body(sftp); } finally { try { sftp.end(); } catch { /* ignore */ } } } export async function sshUpload(args: UploadArgs, hooks: SessionHooks): Promise { const started = Date.now(); // Open local first so we catch symlink / size / type errors before connecting. let localFd: import('node:fs/promises').FileHandle; let localSize: number; try { // O_NOFOLLOW: refuse to open if the leaf is a symlink. Path-policy // already rejected symlinks; this is defense in depth at open time. localFd = await fs.open(args.localPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW); } catch (e) { throw new SshSessionError('local_io_failed', `local open failed: ${(e as Error).message}`); } try { const st = await localFd.stat(); if (!st.isFile()) { throw new SshSessionError('local_io_failed', 'local path is not a regular file'); } localSize = st.size; if (localSize > args.maxBytes) { throw new SshSessionError('output_too_large', `local file ${localSize} > cap ${args.maxBytes}`); } } catch (e) { await localFd.close().catch(() => undefined); if (e instanceof SshSessionError) throw e; throw new SshSessionError('local_io_failed', `local stat failed: ${(e as Error).message}`); } const { client, fingerprint } = await connect(args.connection, hooks, args.timeoutMs); try { return await withSftp(client, async (sftp) => { return await new Promise((resolve, reject) => { let settled = false; let bytes = 0; const settle = (err: Error | null, result?: TransferResult) => { if (settled) return; settled = true; clearTimeout(timer); if (err) reject(err); else if (result) resolve(result); }; const timer = setTimeout(() => { try { client.end(); } catch { /* ignore */ } settle(new SshSessionError('transfer_timeout', `Upload exceeded ${args.timeoutMs} ms`)); }, args.timeoutMs); // Path-validated above; use createReadStream off the open fd. const localStream = createReadStream('', { fd: localFd.fd, autoClose: false }); const remoteStream = sftp.createWriteStream(args.remotePath); localStream.on('data', (chunk: Buffer | string) => { bytes += typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.length; }); localStream.on('error', (e: Error) => settle(new SshSessionError('local_io_failed', sanitizeError(e).message)), ); remoteStream.on('error', (e: Error) => settle(new SshSessionError('remote_io_failed', sanitizeError(e).message)), ); remoteStream.on('close', () => { settle(null, { bytes, durationMs: Date.now() - started, hostFingerprint: fingerprint }); }); localStream.pipe(remoteStream); }); }); } finally { await localFd.close().catch(() => undefined); try { client.end(); } catch { /* ignore */ } clearBuffer(args.connection.privateKeyPem); clearBuffer(args.connection.passphrase); } } export async function sshDownload(args: DownloadArgs, hooks: SessionHooks): Promise { const started = Date.now(); // Refuse to overwrite an existing file; download into a partial sibling that // we rename on success. The caller has already validated localPath is inside // the workspace and contains no symlinks in its ancestry. if (path.isAbsolute(args.localPath) === false) { throw new SshSessionError('local_io_failed', 'localPath must be absolute'); } let leafExists = false; try { await fs.lstat(args.localPath); leafExists = true; } catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') { throw new SshSessionError('local_io_failed', `lstat failed: ${(e as Error).message}`); } } if (leafExists) { throw new SshSessionError('local_target_exists', 'local target already exists; refusing to overwrite'); } const partialSuffix = `.partial-${randomBytes(8).toString('hex')}`; const partialPath = args.localPath + partialSuffix; let partialFd: import('node:fs/promises').FileHandle; try { partialFd = await fs.open( partialPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, 0o600, ); } catch (e) { throw new SshSessionError('local_io_failed', `open partial failed: ${(e as Error).message}`); } const { client, fingerprint } = await connect(args.connection, hooks, args.timeoutMs); let bytes = 0; let failed: SshSessionError | null = null; try { await withSftp(client, async (sftp) => { const remoteStats = await promisify((cb) => sftp.stat(args.remotePath, (err, stats) => cb(err ?? undefined, stats as import('ssh2').Stats)), ); if (!remoteStats || typeof remoteStats.size !== 'number') { throw new SshSessionError('remote_io_failed', 'remote stat returned no size'); } if (remoteStats.size > args.maxBytes) { throw new SshSessionError( 'remote_too_large', `remote ${remoteStats.size} > cap ${args.maxBytes}`, ); } await new Promise((resolve, reject) => { let settled = false; const settle = (err: Error | null) => { if (settled) return; settled = true; clearTimeout(timer); if (err) reject(err); else resolve(); }; const timer = setTimeout(() => { try { client.end(); } catch { /* ignore */ } settle(new SshSessionError('transfer_timeout', `Download exceeded ${args.timeoutMs} ms`)); }, args.timeoutMs); const rs = sftp.createReadStream(args.remotePath); const ws = createWriteStreamFromFd(partialFd.fd); rs.on('data', (chunk: Buffer | string) => { bytes += typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.length; if (bytes > args.maxBytes) { settle(new SshSessionError('remote_too_large', `download exceeded cap ${args.maxBytes}`)); try { rs.destroy(); } catch { /* ignore */ } } }); rs.on('error', (e: Error) => settle(new SshSessionError('remote_io_failed', sanitizeError(e).message)), ); ws.on('error', (e: Error) => settle(new SshSessionError('local_io_failed', sanitizeError(e).message)), ); ws.on('finish', () => settle(null)); rs.pipe(ws); }); }); } catch (e) { failed = e instanceof SshSessionError ? e : new SshSessionError('remote_io_failed', (e as Error).message); } finally { await partialFd.close().catch(() => undefined); try { client.end(); } catch { /* ignore */ } clearBuffer(args.connection.privateKeyPem); clearBuffer(args.connection.passphrase); } if (failed) { await fs.unlink(partialPath).catch(() => undefined); throw failed; } try { await fs.rename(partialPath, args.localPath); } catch (e) { await fs.unlink(partialPath).catch(() => undefined); throw new SshSessionError('local_io_failed', `rename partial failed: ${(e as Error).message}`); } return { bytes, durationMs: Date.now() - started, hostFingerprint: fingerprint }; } /** createWriteStream against an existing fd. autoClose: false so the caller closes. */ function createWriteStreamFromFd(fd: number) { return createWriteStream('', { fd, autoClose: false }); } // ────────────────────────────────────────────────────────────────────── // Phase 3: SSH Console — interactive shell channel // // Opens a connection (reusing the same preflight / host-key / algorithm // allowlist path as sshExec) and then requests a PTY-backed shell. The // caller (engine/tools/ssh-console.ts) wraps the returned ClientChannel // in a ConsoleSession and is responsible for closing it (channel.end / // client.end on close path). We do NOT zero connection.privateKeyPem // here — the long-lived console session keeps the Client alive past // this call, and the Channel must remain usable. The caller clears the // PEM buffer once the session closes. // ────────────────────────────────────────────────────────────────────── export interface OpenShellArgs { connection: ResolvedConnection; cols: number; rows: number; /** Wall-clock cap for the connect+shell handshake (ms). */ timeoutMs: number; } export interface OpenShellResult { channel: import('ssh2').ClientChannel; client: import('ssh2').Client; hostFingerprint: string; } export async function openShellChannel(args: OpenShellArgs): Promise { // No-op hooks: for interactive shells we keep the existing semantics // (host_key_not_verified is rejected before we get here in the tool // layer; first_observe / mismatch on a previously-verified key would // throw the standard SshSessionError, which the caller surfaces). const noopHooks: SessionHooks = { onFirstObserve: async () => null, onMismatch: async () => null, }; const { client, fingerprint } = await connect(args.connection, noopHooks, args.timeoutMs); try { const channel = await new Promise((resolve, reject) => { client.shell( { cols: args.cols, rows: args.rows, term: 'xterm-256color' }, (err: Error | undefined, ch: import('ssh2').ClientChannel | undefined) => { if (err || !ch) { return reject( new SshSessionError( 'exec_failed', sanitizeError(err ?? new Error('shell() returned no channel')).message, ), ); } resolve(ch); }, ); }); return { channel, client, hostFingerprint: fingerprint }; } catch (e) { // shell() failed — close the client so the socket doesn't leak. try { client.end(); } catch { /* ignore */ } throw e; } }