fix(desktop): log what ssh did when an SSH connect fails

SshConnection.open() handed the classified error to its caller and
logged nothing about the failure. desktop.log showed only "connecting"
and then "connection closed", and the renderer shows only friendly copy
for the error kind. A connect that dies right after TCP setup (#80836)
therefore left no exit code, close signal or stderr anywhere. Log them,
redacted, for both the mux and no-mux connect paths.
This commit is contained in:
Hermes Agent
2026-09-24 23:43:55 -05:00
committed by brooklyn!
parent 99a161531c
commit 69948c0057
2 changed files with 57 additions and 4 deletions

View File

@@ -567,6 +567,39 @@ test('no-mux: open() classifies auth failure', async () => {
await assert.rejects(conn.open(), (err: any) => err.kind === 'auth-failed')
})
test('open() records what the failed ssh did in the desktop log (#80836)', async () => {
// The renderer only shows friendly copy for the kind, so the log is the one
// place a connect that dies right after TCP setup can be diagnosed from.
for (const mux of [false, true]) {
const logs: string[] = []
const spawnFn = scriptedSpawn(args =>
args.includes('check') ? { code: 255, stderr: 'no control path' } : { signal: 'SIGTERM', stderr: '' }
)
const controlDir = path.join(os.tmpdir(), `hermes-ssh-connect-log-${process.pid}-${Date.now()}`)
const conn = new SshConnection(
{ host: 'box', user: 'me' },
{ spawnFn, mux, controlDir, rememberLog: line => logs.push(line) }
)
await assert.rejects(conn.open())
fs.rmSync(controlDir, { recursive: true, force: true })
assert.ok(
logs.some(line => /connect to me@box:22 failed \(kind=unknown, exit=null, signal=SIGTERM\): \(empty\)/.test(line)),
`mux=${mux}: ${logs.join(' | ')}`
)
}
const logs: string[] = []
const spawnFn = scriptedSpawn([{ code: 255, stderr: 'me@box: Permission denied (publickey).' }])
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false, rememberLog: line => logs.push(line) })
await assert.rejects(conn.open())
assert.ok(logs.some(line => /failed \(kind=auth-failed, exit=255, signal=none\): me@box: Permission denied/.test(line)))
})
test('runSsh keeps Node close signal on the result', async () => {
const spawnFn = () => fakeChild({ signal: 'SIGTERM', stderr: '' })
const result: any = await runSsh(['box'], { timeoutMs: 5000, spawnFn })

View File

@@ -736,6 +736,26 @@ class SshConnection {
return err
}
// The classified error only reaches the caller (the renderer shows friendly
// copy for its kind), so record what ssh actually did — exit code, close
// signal, stderr — in desktop.log. Without it a connect that dies right after
// TCP setup leaves nothing to diagnose it by (#80836).
_connectFailed(raw) {
const err = this._fail(raw, SSH_ERROR.UNREACHABLE)
if (err?.kind !== 'superseded') {
const code = raw && typeof raw === 'object' && 'code' in raw ? String(raw.code) : '?'
const stderr = String(sshCloseStderr(raw)).trim().slice(-500) || '(empty)'
this._logLine(
`connect to ${target(this.user, this.host)}:${this.port} failed ` +
`(kind=${err.kind}, exit=${code}, signal=${sshCloseSignal(raw) || 'none'}): ${stderr}`
)
}
return err
}
// Open the connection. Mux: start the persistent ControlMaster (idempotent —
// a live master is a no-op). No-mux: there is no master; validate auth +
// reachability with a one-shot `ssh true` so failures classify identically.
@@ -788,11 +808,11 @@ class SshConnection {
signal
})
} catch (error) {
throw this._fail(error, SSH_ERROR.UNREACHABLE)
throw this._connectFailed(error)
}
if (!sshCloseOk(result)) {
throw this._fail(result, SSH_ERROR.UNREACHABLE)
throw this._connectFailed(result)
}
this._opened = true
@@ -808,11 +828,11 @@ class SshConnection {
try {
result = await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn, signal })
} catch (error) {
throw this._fail(error, SSH_ERROR.UNREACHABLE)
throw this._connectFailed(error)
}
if (!sshCloseOk(result)) {
throw this._fail(result, SSH_ERROR.UNREACHABLE)
throw this._connectFailed(result)
}
this._opened = true