maestro/src/ssh/session.ts
oss-sync 02c7dfdd83
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (7d64ee2)
2026-06-05 05:42:11 +00:00

794 lines
29 KiB
TypeScript

/**
* 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<string, string>;
/** 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<Client> {
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<Client>((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<SshSessionError> {
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<TestResult> {
// 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<ExecResult> {
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<ExecResult> {
return new Promise<ExecResult>((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<T>(fn: (cb: (err: Error | undefined, value: T) => void) => void): Promise<T> {
return new Promise<T>((resolve, reject) => {
fn((err, v) => (err ? reject(err) : resolve(v)));
});
}
async function withSftp<T>(
client: Client,
body: (sftp: import('ssh2').SFTPWrapper) => Promise<T>,
): Promise<T> {
const sftp = await promisify<import('ssh2').SFTPWrapper>((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<TransferResult> {
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<TransferResult>((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<TransferResult> {
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<import('ssh2').Stats>((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<void>((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<OpenShellResult> {
// 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<import('ssh2').ClientChannel>((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;
}
}