794 lines
29 KiB
TypeScript
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;
|
|
}
|
|
}
|