diff --git a/apps/desktop/electron/ssh-connection.cjs b/apps/desktop/electron/ssh-connection.cjs new file mode 100644 index 00000000000..9ed4c10f14e --- /dev/null +++ b/apps/desktop/electron/ssh-connection.cjs @@ -0,0 +1,451 @@ +/** + * ssh-connection.cjs + * + * Pure, electron-free OpenSSH ControlMaster connection manager for Desktop SSH + * remote mode. Uses the system `ssh` client (not a JS SSH library) so it + * inherits ~/.ssh/config, the agent, jump hosts (ProxyJump), and hardware keys + * for free — the same rationale as tools/environments/ssh.py. + * + * Kept standalone (no `require('electron')`) so it can be unit-tested with + * `node --test` — same pattern as connection-config.cjs / dashboard-token.cjs. + * main.cjs requires this and wires it into the electron-coupled lifecycle. + * + * Conventions mirrored from tools/environments/ssh.py: + * - ControlMaster=auto + ControlPersist so one TCP/auth handshake is reused + * across exec/forward operations. + * - Hashed control-socket filename under a short tmpdir to stay under the + * 104-byte sun_path limit macOS enforces on Unix domain sockets + * (ssh.py:53-67 rationale applies verbatim). + * - BatchMode=yes for every programmatic invocation — a spawned ssh must + * never hang on an interactive prompt (passphrase / 2FA). If auth needs + * interactivity we fail fast and tell the user to load the key into their + * agent. + * + * Host-key policy: StrictHostKeyChecking=accept-new (trust-on-first-use, log + * the fingerprint), never `no`. A host-key *change* fails closed with the + * verbatim OpenSSH error surfaced to the UI. + * + * Every operation is raced against a hard timeout. A half-open TCP connection + * after laptop sleep can leave ssh hanging indefinitely rather than erroring; + * timeout is treated as connection-dead so the caller does a full reconnect + * rather than retrying in place (VS Code's agent host does the same). + */ + +const { spawn } = require('node:child_process') +const crypto = require('node:crypto') +const net = require('node:net') +const os = require('node:os') +const path = require('node:path') + +const DEFAULT_CONNECT_TIMEOUT_MS = 15_000 +const DEFAULT_EXEC_TIMEOUT_MS = 20_000 +const DEFAULT_FORWARD_TIMEOUT_MS = 15_000 +const CONTROL_PERSIST_SECONDS = 300 + +// --------------------------------------------------------------------------- +// Token / secret redaction +// --------------------------------------------------------------------------- + +// Every lifecycle log line in SSH mode passes through this before it reaches +// rememberLog/desktop.log. The step-3 spawn command line embeds the session +// token (HERMES_DASHBOARD_SESSION_TOKEN=); it must never be logged raw. +// We also scrub the URL/header carriers the dashboard protocol uses so a +// forwarded base URL or a copied curl line can't leak a credential. +// +// Patterns scrubbed (case-insensitive where it matters): +// - HERMES_DASHBOARD_SESSION_TOKEN= +// - X-Hermes-Session-Token: / X-Hermes-Session-Token= +// - Authorization: Bearer +// - ?token= / &token= (the WS auth param) +// - ?ticket= / &ticket= (the OAuth ws-ticket param) +const _REDACTIONS = [ + [/(HERMES_DASHBOARD_SESSION_TOKEN=)(\S+)/g, '$1'], + [/(X-Hermes-Session-Token["']?\s*[:=]\s*["']?)([^\s"'&]+)/gi, '$1'], + [/(Authorization["']?\s*:\s*Bearer\s+)(\S+)/gi, '$1'], + [/([?&](?:token|ticket)=)([^\s&"']+)/gi, '$1'] +] + +function redactSecrets(text) { + let out = String(text == null ? '' : text) + for (const [re, repl] of _REDACTIONS) { + out = out.replace(re, repl) + } + return out +} + +// --------------------------------------------------------------------------- +// Control-socket path +// --------------------------------------------------------------------------- + +// Hash user@host:port to a short, stable, filesystem-safe socket id. Stable +// across reconnects so ControlMaster reuse works; short so the full path stays +// well under sun_path's 104-byte limit even under macOS's deeply nested +// $TMPDIR (/var/folders/xx/yy/T/...). Mirrors ssh.py:53-67. +function controlSocketPath(user, host, port, baseDir) { + const dir = baseDir || path.join(os.tmpdir(), 'hermes-desktop-ssh') + const id = crypto.createHash('sha256').update(`${user}@${host}:${port}`).digest('hex').slice(0, 16) + return path.join(dir, `${id}.sock`) +} + +// --------------------------------------------------------------------------- +// Command construction (pure — the unit tests exercise these directly) +// --------------------------------------------------------------------------- + +function baseSshOptions(controlPath, connectTimeoutMs) { + const connectSecs = Math.max(1, Math.round((connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS) / 1000)) + return [ + '-o', `ControlPath=${controlPath}`, + '-o', 'ControlMaster=auto', + '-o', `ControlPersist=${CONTROL_PERSIST_SECONDS}`, + '-o', 'BatchMode=yes', + '-o', 'StrictHostKeyChecking=accept-new', + '-o', `ConnectTimeout=${connectSecs}` + ] +} + +// Per-host args shared by exec, the master open, and forward control commands: +// non-default port and explicit identity file. +function hostArgs({ port, keyPath }) { + const args = [] + if (port && Number(port) !== 22) { + args.push('-p', String(port)) + } + if (keyPath) { + args.push('-i', keyPath) + } + return args +} + +function target(user, host) { + return user ? `${user}@${host}` : host +} + +// `ssh ` — one-shot over the control connection. +function buildExecArgs(conn, remoteCommand, connectTimeoutMs) { + return [ + ...baseSshOptions(conn.controlPath, connectTimeoutMs), + ...hostArgs(conn), + target(conn.user, conn.host), + remoteCommand + ] +} + +// `ssh -O ` — control-command against the running master +// (check / forward / cancel / exit). -O commands don't take a remote command. +function buildControlArgs(conn, op, extra = [], connectTimeoutMs) { + return [ + '-O', op, + ...extra, + ...baseSshOptions(conn.controlPath, connectTimeoutMs), + ...hostArgs(conn), + target(conn.user, conn.host) + ] +} + +// Open the master explicitly: `-M -N -f` puts ssh into the background once the +// master is up, so the spawn resolves when the connection is established (or +// fails fast under BatchMode if auth is non-interactive-only). +function buildMasterArgs(conn, connectTimeoutMs) { + return [ + '-M', '-N', '-f', + ...baseSshOptions(conn.controlPath, connectTimeoutMs), + ...hostArgs(conn), + target(conn.user, conn.host) + ] +} + +// Local forward spec for `-O forward -L ::`. +// Bind the local end to 127.0.0.1 ONLY — never 0.0.0.0 — so the tunnel does +// not re-expose the remote dashboard to the client's LAN. +function forwardSpec(localPort, remotePort, remoteHost = '127.0.0.1') { + return `127.0.0.1:${localPort}:${remoteHost}:${remotePort}` +} + +// --------------------------------------------------------------------------- +// Error classification — distinct, actionable messages for the UI +// --------------------------------------------------------------------------- + +const SSH_ERROR = { + UNREACHABLE: 'unreachable', + AUTH_FAILED: 'auth-failed', + HOST_KEY_CHANGED: 'host-key-changed', + TIMEOUT: 'timeout', + UNKNOWN: 'unknown' +} + +// Map raw ssh stderr to a stable error kind. Order matters: the host-key-change +// banner also contains "WARNING"/"Offending", check it before generic auth. +function classifySshError(stderr) { + const text = String(stderr || '') + if (/REMOTE HOST IDENTIFICATION HAS CHANGED|Host key verification failed|Offending (?:key|ECDSA|RSA|ED25519)/i.test(text)) { + return SSH_ERROR.HOST_KEY_CHANGED + } + if (/Permission denied|Too many authentication failures|no matching host key|publickey|password|keyboard-interactive/i.test(text)) { + return SSH_ERROR.AUTH_FAILED + } + if (/Could not resolve hostname|Connection refused|Connection timed out|No route to host|Network is unreachable|Operation timed out|port \d+: Connection/i.test(text)) { + return SSH_ERROR.UNREACHABLE + } + return SSH_ERROR.UNKNOWN +} + +function sshErrorMessage(kind, conn, stderr) { + const host = target(conn.user, conn.host) + switch (kind) { + case SSH_ERROR.HOST_KEY_CHANGED: + return ( + `The host key for ${host} has CHANGED since you last connected. ` + + `This could be a man-in-the-middle attack, or the server was reinstalled. ` + + `SSH refused to connect. Verify the change is expected, then remove the old key ` + + `with \`ssh-keygen -R ${conn.host}\` and reconnect.\n\n${String(stderr || '').trim()}` + ) + case SSH_ERROR.AUTH_FAILED: + return ( + `SSH authentication to ${host} failed. Desktop runs ssh non-interactively ` + + `(BatchMode), so a key requiring a passphrase or 2FA must be loaded into your ` + + `ssh-agent first (e.g. \`ssh-add ~/.ssh/id_ed25519\`), or set an IdentityFile in ` + + `~/.ssh/config. Original error: ${String(stderr || '').trim()}` + ) + case SSH_ERROR.UNREACHABLE: + return `Could not reach ${host} over SSH. Check the host, port, and your network. Original error: ${String(stderr || '').trim()}` + case SSH_ERROR.TIMEOUT: + return `SSH operation to ${host} timed out. The connection may be half-open (e.g. after sleep); reconnecting.` + default: + return `SSH error connecting to ${host}: ${String(stderr || '').trim() || 'unknown failure'}` + } +} + +// --------------------------------------------------------------------------- +// Spawn helper — runs an ssh invocation, races it against a hard timeout +// --------------------------------------------------------------------------- + +// Resolves { code, stdout, stderr }. On timeout the child is SIGKILLed and the +// promise rejects with err.kind = TIMEOUT. `spawnFn` is injectable for tests. +function runSsh(args, { timeoutMs, spawnFn = spawn, stdin = 'ignore' } = {}) { + return new Promise((resolve, reject) => { + let child + try { + child = spawnFn('ssh', args, { stdio: [stdin === 'ignore' ? 'ignore' : 'pipe', 'pipe', 'pipe'] }) + } catch (error) { + reject(error) + return + } + + let stdout = '' + let stderr = '' + let settled = false + + const timer = setTimeout(() => { + if (settled) return + settled = true + try { + child.kill('SIGKILL') + } catch { + // already gone + } + const err = new Error(`ssh timed out after ${timeoutMs}ms`) + err.kind = SSH_ERROR.TIMEOUT + reject(err) + }, timeoutMs) + + child.stdout?.on('data', d => { + stdout += d.toString() + }) + child.stderr?.on('data', d => { + stderr += d.toString() + }) + child.on('error', error => { + if (settled) return + settled = true + clearTimeout(timer) + reject(error) + }) + child.on('close', code => { + if (settled) return + settled = true + clearTimeout(timer) + resolve({ code, stdout, stderr }) + }) + }) +} + +// --------------------------------------------------------------------------- +// SshConnection — the public manager +// --------------------------------------------------------------------------- + +class SshConnection { + /** + * @param {{host:string, user?:string, port?:number, keyPath?:string}} cfg + * @param {{ spawnFn?, rememberLog?, controlDir?, connectTimeoutMs?, execTimeoutMs?, forwardTimeoutMs? }} [opts] + */ + constructor(cfg, opts = {}) { + if (!cfg || !cfg.host) { + throw new Error('SshConnection requires a host.') + } + this.host = cfg.host + this.user = cfg.user || '' + this.port = cfg.port ? Number(cfg.port) : 22 + this.keyPath = cfg.keyPath || '' + this.controlPath = controlSocketPath(this.user, this.host, this.port, opts.controlDir) + + this._spawnFn = opts.spawnFn || spawn + this._log = typeof opts.rememberLog === 'function' ? opts.rememberLog : () => {} + this._connectTimeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS + this._execTimeoutMs = opts.execTimeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS + this._forwardTimeoutMs = opts.forwardTimeoutMs ?? DEFAULT_FORWARD_TIMEOUT_MS + this._opened = false + } + + // Lifecycle logging — ALWAYS through redaction. + _logLine(msg) { + this._log(redactSecrets(`[ssh] ${msg}`)) + } + + // Throw a classified, UI-ready error from an ssh result/exception. + _fail(stderrOrErr, fallbackKind = SSH_ERROR.UNKNOWN) { + if (stderrOrErr && stderrOrErr.kind === SSH_ERROR.TIMEOUT) { + const err = new Error(sshErrorMessage(SSH_ERROR.TIMEOUT, this)) + err.kind = SSH_ERROR.TIMEOUT + return err + } + const stderr = typeof stderrOrErr === 'string' ? stderrOrErr : stderrOrErr?.message || '' + const kind = stderr ? classifySshError(stderr) : fallbackKind + const err = new Error(sshErrorMessage(kind, this, stderr)) + err.kind = kind + return err + } + + // Open the persistent ControlMaster. Idempotent: if a master socket is + // already alive (`-O check` succeeds), this is a no-op. + async open() { + if (await this.isAlive()) { + this._opened = true + return + } + const args = buildMasterArgs(this, this._connectTimeoutMs) + this._logLine(`opening control master to ${target(this.user, this.host)}:${this.port}`) + let result + try { + result = await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn }) + } catch (error) { + throw this._fail(error, SSH_ERROR.UNREACHABLE) + } + if (result.code !== 0) { + throw this._fail(result.stderr, SSH_ERROR.UNREACHABLE) + } + this._opened = true + this._logLine('control master established') + } + + // `-O check` against the master socket. True iff the master is alive. + async isAlive() { + const args = buildControlArgs(this, 'check', [], this._connectTimeoutMs) + try { + const result = await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn }) + return result.code === 0 + } catch { + return false + } + } + + // One-shot remote command over the control connection. Resolves the trimmed + // stdout; rejects with a classified error on non-zero exit or timeout. + async exec(remoteCommand, { timeoutMs } = {}) { + const args = buildExecArgs(this, remoteCommand, this._connectTimeoutMs) + let result + try { + result = await runSsh(args, { timeoutMs: timeoutMs ?? this._execTimeoutMs, spawnFn: this._spawnFn }) + } catch (error) { + throw this._fail(error) + } + if (result.code !== 0) { + throw this._fail(result.stderr) + } + return result.stdout + } + + // Establish a local→remote forward against the running master. + // 127.0.0.1: → :. + async forward(localPort, remotePort, remoteHost = '127.0.0.1') { + const spec = forwardSpec(localPort, remotePort, remoteHost) + const args = buildControlArgs(this, 'forward', ['-L', spec], this._connectTimeoutMs) + this._logLine(`forwarding 127.0.0.1:${localPort} -> ${remoteHost}:${remotePort}`) + let result + try { + result = await runSsh(args, { timeoutMs: this._forwardTimeoutMs, spawnFn: this._spawnFn }) + } catch (error) { + throw this._fail(error) + } + if (result.code !== 0) { + throw this._fail(result.stderr) + } + } + + // Cancel a previously-established forward. Best-effort: a failure here is + // logged but not thrown (the master close tears everything down anyway). + async cancelForward(localPort, remotePort, remoteHost = '127.0.0.1') { + const spec = forwardSpec(localPort, remotePort, remoteHost) + const args = buildControlArgs(this, 'cancel', ['-L', spec], this._connectTimeoutMs) + try { + await runSsh(args, { timeoutMs: this._forwardTimeoutMs, spawnFn: this._spawnFn }) + this._logLine(`cancelled forward 127.0.0.1:${localPort}`) + } catch (error) { + this._logLine(`cancelForward failed (ignored): ${error.message}`) + } + } + + // Tear down the master. Best-effort; never throws. + async close() { + if (!this._opened) return + const args = buildControlArgs(this, 'exit', [], this._connectTimeoutMs) + try { + await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn }) + this._logLine('control master closed') + } catch (error) { + this._logLine(`close failed (ignored): ${error.message}`) + } finally { + this._opened = false + } + } +} + +// --------------------------------------------------------------------------- +// Free local port — for the tunnel's local end. Bind 127.0.0.1:0, read the +// kernel-assigned port, release. There is a benign TOCTOU window between +// release and the forward grabbing it; the forward failing is caught upstream +// and retried with a fresh port. +// --------------------------------------------------------------------------- + +function pickLocalPort() { + return new Promise((resolve, reject) => { + const server = net.createServer() + server.unref() + server.on('error', reject) + server.listen(0, '127.0.0.1', () => { + const { port } = server.address() + server.close(() => resolve(port)) + }) + }) +} + +module.exports = { + CONTROL_PERSIST_SECONDS, + DEFAULT_CONNECT_TIMEOUT_MS, + DEFAULT_EXEC_TIMEOUT_MS, + DEFAULT_FORWARD_TIMEOUT_MS, + SSH_ERROR, + SshConnection, + baseSshOptions, + buildControlArgs, + buildExecArgs, + buildMasterArgs, + classifySshError, + controlSocketPath, + forwardSpec, + hostArgs, + pickLocalPort, + redactSecrets, + runSsh, + sshErrorMessage, + target +} diff --git a/apps/desktop/electron/ssh-connection.test.cjs b/apps/desktop/electron/ssh-connection.test.cjs new file mode 100644 index 00000000000..86765566a34 --- /dev/null +++ b/apps/desktop/electron/ssh-connection.test.cjs @@ -0,0 +1,283 @@ +/** + * Tests for electron/ssh-connection.cjs. + * + * Run with: node --test electron/ssh-connection.test.cjs + * (Wired into npm test:desktop:platforms in package.json.) + * + * Pure, electron-free: command construction, secret redaction, error + * classification, and the SshConnection lifecycle are exercised with an + * injected fake `spawn` so no real ssh process is started. + */ + +const test = require('node:test') +const assert = require('node:assert/strict') +const { EventEmitter } = require('node:events') + +const { + SSH_ERROR, + SshConnection, + baseSshOptions, + buildControlArgs, + buildExecArgs, + buildMasterArgs, + classifySshError, + controlSocketPath, + forwardSpec, + hostArgs, + redactSecrets, + sshErrorMessage, + target +} = require('./ssh-connection.cjs') + +// --- secret redaction ------------------------------------------------------- + +test('redactSecrets scrubs the spawn-time session token env var', () => { + const line = 'setsid env HERMES_DASHBOARD_SESSION_TOKEN=abc123deadbeef HERMES_DESKTOP=1 hermes dashboard' + const out = redactSecrets(line) + assert.ok(!out.includes('abc123deadbeef')) + assert.match(out, /HERMES_DASHBOARD_SESSION_TOKEN=/) + // non-secret env vars are preserved + assert.match(out, /HERMES_DESKTOP=1/) +}) + +test('redactSecrets scrubs ?token= and ?ticket= URL params', () => { + assert.match(redactSecrets('ws://127.0.0.1:5000/api/ws?token=supersecret'), /\?token=/) + assert.match(redactSecrets('ws://127.0.0.1:5000/api/ws?ticket=onetimeticket'), /\?ticket=/) + assert.match(redactSecrets('GET /x?a=1&token=zzz HTTP'), /&token=/) + assert.ok(!redactSecrets('?token=supersecret').includes('supersecret')) +}) + +test('redactSecrets scrubs Authorization and X-Hermes-Session-Token headers', () => { + assert.match(redactSecrets('Authorization: Bearer tok_9999'), /Authorization: Bearer /) + assert.ok(!redactSecrets('Authorization: Bearer tok_9999').includes('tok_9999')) + assert.match(redactSecrets('X-Hermes-Session-Token: hdr_888'), /X-Hermes-Session-Token: ?/) + assert.ok(!redactSecrets('X-Hermes-Session-Token: hdr_888').includes('hdr_888')) +}) + +test('redactSecrets handles null/undefined and non-secret text untouched', () => { + assert.equal(redactSecrets(null), '') + assert.equal(redactSecrets(undefined), '') + assert.equal(redactSecrets('uname -s -m'), 'uname -s -m') +}) + +// --- control-socket path ---------------------------------------------------- + +test('controlSocketPath is stable, short, and host-distinct', () => { + const a = controlSocketPath('me', 'box1', 22, '/tmp/d') + const a2 = controlSocketPath('me', 'box1', 22, '/tmp/d') + const b = controlSocketPath('me', 'box2', 22, '/tmp/d') + assert.equal(a, a2, 'same triple → same socket (ControlMaster reuse)') + assert.notEqual(a, b, 'different host → different socket') + // 16 hex chars + .sock keeps the basename short for sun_path 104-byte limit + assert.match(a, /\/[0-9a-f]{16}\.sock$/) +}) + +// --- command construction --------------------------------------------------- + +test('baseSshOptions carries the house ControlMaster/BatchMode/accept-new policy', () => { + const opts = baseSshOptions('/tmp/x.sock', 15000) + const joined = opts.join(' ') + assert.match(joined, /ControlPath=\/tmp\/x\.sock/) + assert.match(joined, /ControlMaster=auto/) + assert.match(joined, /ControlPersist=\d+/) + assert.match(joined, /BatchMode=yes/) + assert.match(joined, /StrictHostKeyChecking=accept-new/) + assert.match(joined, /ConnectTimeout=15/) + assert.ok(!joined.includes('StrictHostKeyChecking=no'), 'never disables host-key checking') +}) + +test('hostArgs adds -p only for non-default port and -i only with a key', () => { + assert.deepEqual(hostArgs({ port: 22 }), []) + assert.deepEqual(hostArgs({ port: 2222 }), ['-p', '2222']) + assert.deepEqual(hostArgs({ port: 22, keyPath: '/k' }), ['-i', '/k']) + assert.deepEqual(hostArgs({ port: 2200, keyPath: '/k' }), ['-p', '2200', '-i', '/k']) +}) + +test('target builds user@host or bare host', () => { + assert.equal(target('me', 'box'), 'me@box') + assert.equal(target('', 'box'), 'box') +}) + +test('buildExecArgs ends with host then the remote command', () => { + const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' } + const args = buildExecArgs(conn, 'command -v hermes', 15000) + assert.equal(args[args.length - 1], 'command -v hermes') + assert.equal(args[args.length - 2], 'me@box') + assert.ok(args.includes('BatchMode=yes')) +}) + +test('buildControlArgs places -O first and never appends a remote command', () => { + const conn = { user: 'me', host: 'box', port: 2222, keyPath: '/k', controlPath: '/tmp/x.sock' } + const args = buildControlArgs(conn, 'forward', ['-L', forwardSpec(5000, 6000)], 15000) + assert.equal(args[0], '-O') + assert.equal(args[1], 'forward') + assert.ok(args.includes('-L')) + assert.ok(args.includes('127.0.0.1:5000:127.0.0.1:6000')) + assert.equal(args[args.length - 1], 'me@box') +}) + +test('buildMasterArgs requests a backgrounded master (-M -N -f)', () => { + const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' } + const args = buildMasterArgs(conn, 15000) + assert.ok(args.includes('-M')) + assert.ok(args.includes('-N')) + assert.ok(args.includes('-f')) +}) + +test('forwardSpec binds the local end to 127.0.0.1 only', () => { + assert.equal(forwardSpec(5000, 6000), '127.0.0.1:5000:127.0.0.1:6000') + assert.ok(forwardSpec(5000, 6000).startsWith('127.0.0.1:')) + assert.ok(!forwardSpec(5000, 6000).startsWith('0.0.0.0')) +}) + +// --- error classification --------------------------------------------------- + +test('classifySshError detects a changed host key (fail-closed)', () => { + assert.equal( + classifySshError('@@@@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @@@@'), + SSH_ERROR.HOST_KEY_CHANGED + ) + assert.equal(classifySshError('Host key verification failed.'), SSH_ERROR.HOST_KEY_CHANGED) + assert.equal(classifySshError('Offending ECDSA key in /home/u/.ssh/known_hosts:5'), SSH_ERROR.HOST_KEY_CHANGED) +}) + +test('classifySshError detects auth failure', () => { + assert.equal(classifySshError('Permission denied (publickey).'), SSH_ERROR.AUTH_FAILED) + assert.equal(classifySshError('Too many authentication failures'), SSH_ERROR.AUTH_FAILED) +}) + +test('classifySshError detects unreachable', () => { + assert.equal(classifySshError('ssh: Could not resolve hostname nope'), SSH_ERROR.UNREACHABLE) + assert.equal(classifySshError('connect to host x port 22: Connection refused'), SSH_ERROR.UNREACHABLE) +}) + +test('sshErrorMessage gives actionable guidance for auth and host-key-change', () => { + const conn = { user: 'me', host: 'box', port: 22 } + assert.match(sshErrorMessage(SSH_ERROR.AUTH_FAILED, conn, 'Permission denied'), /ssh-agent|ssh-add|IdentityFile/) + assert.match(sshErrorMessage(SSH_ERROR.HOST_KEY_CHANGED, conn, 'CHANGED'), /ssh-keygen -R box/) +}) + +// --- SshConnection lifecycle with injected fake spawn ----------------------- + +// A fake child process that emits a scripted result on next tick. +function fakeChild({ code = 0, stdout = '', stderr = '', errorEvent = null, hang = false } = {}) { + const child = new EventEmitter() + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + child.kill = () => { + child._killed = true + } + if (hang) { + return child // never emits close → drives the timeout path + } + process.nextTick(() => { + if (errorEvent) { + child.emit('error', errorEvent) + return + } + if (stdout) child.stdout.emit('data', Buffer.from(stdout)) + if (stderr) child.stderr.emit('data', Buffer.from(stderr)) + child.emit('close', code) + }) + return child +} + +// Build a spawnFn that returns scripted children per ssh invocation, recording +// the args it was called with. +function scriptedSpawn(scripts) { + const calls = [] + let i = 0 + const fn = (_cmd, args) => { + calls.push(args) + const script = typeof scripts === 'function' ? scripts(args, i) : scripts[Math.min(i, scripts.length - 1)] + i += 1 + return fakeChild(script || {}) + } + fn.calls = calls + return fn +} + +test('open() establishes the master when not already alive', async () => { + // `-O check` fails first (not alive) → master opens (code 0). Track which + // ssh ops ran rather than re-probing with the same always-failing check. + const ops = [] + const spawnFn = scriptedSpawn(args => { + ops.push(args.includes('check') ? 'check' : args.includes('-M') ? 'master' : 'other') + if (args.includes('check')) return { code: 255, stderr: 'no control path' } + return { code: 0 } + }) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' }) + await conn.open() + assert.deepEqual(ops, ['check', 'master'], 'probes liveness first, then opens the master') +}) + +test('open() is a no-op when the master is already alive', async () => { + const ops = [] + const spawnFn = scriptedSpawn(args => { + ops.push(args.includes('check') ? 'check' : 'master') + return { code: 0 } // check succeeds → already alive + }) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' }) + await conn.open() + assert.deepEqual(ops, ['check'], 'alive master → no second spawn to open it') +}) + +test('open() surfaces a classified auth error', async () => { + const spawnFn = scriptedSpawn(args => { + if (args.includes('check')) return { code: 255 } + return { code: 255, stderr: 'Permission denied (publickey).' } + }) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' }) + await assert.rejects(() => conn.open(), err => { + assert.equal(err.kind, SSH_ERROR.AUTH_FAILED) + assert.match(err.message, /ssh-agent|ssh-add/) + return true + }) +}) + +test('exec() returns stdout on success and rejects (classified) on failure', async () => { + const okSpawn = scriptedSpawn([{ code: 0, stdout: 'Linux\n' }]) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn: okSpawn, controlDir: '/tmp/d' }) + assert.equal((await conn.exec('uname -s')).trim(), 'Linux') + + const failSpawn = scriptedSpawn([{ code: 1, stderr: 'ssh: Could not resolve hostname box' }]) + const conn2 = new SshConnection({ host: 'box', user: 'me' }, { spawnFn: failSpawn, controlDir: '/tmp/d' }) + await assert.rejects(() => conn2.exec('uname -s'), err => { + assert.equal(err.kind, SSH_ERROR.UNREACHABLE) + return true + }) +}) + +test('exec() treats a hung ssh as a timeout (half-open connection)', async () => { + const spawnFn = scriptedSpawn([{ hang: true }]) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' }) + await assert.rejects(() => conn.exec('uname -s', { timeoutMs: 30 }), err => { + assert.equal(err.kind, SSH_ERROR.TIMEOUT) + return true + }) +}) + +test('forward() issues -O forward with a loopback-bound -L spec', async () => { + const spawnFn = scriptedSpawn([{ code: 0 }]) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' }) + await conn.forward(5000, 6000) + const args = spawnFn.calls[0] + assert.equal(args[0], '-O') + assert.equal(args[1], 'forward') + assert.ok(args.includes('127.0.0.1:5000:127.0.0.1:6000')) +}) + +test('lifecycle logging passes through redaction', async () => { + const logs = [] + const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 })) + const conn = new SshConnection( + { host: 'box', user: 'me' }, + { spawnFn, controlDir: '/tmp/d', rememberLog: l => logs.push(l) } + ) + await conn.open() + // none of the emitted log lines may carry a raw token-shaped secret + for (const line of logs) { + assert.ok(!/token=[^<]/.test(line)) + } + assert.ok(logs.some(l => l.includes('[ssh]'))) +}) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 81e855451f8..8e81b61bf25 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -37,7 +37,7 @@ "test:desktop:nsis": "node scripts/test-desktop.mjs nsis", "test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:fresh": "node scripts/test-desktop.mjs fresh", - "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs", + "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/ssh-connection.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs", "typecheck": "tsc -p . --noEmit", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix",