feat(desktop): interim ssh -tt remote terminal (SSH mode only; tracked for /api/terminal)

Make the integrated terminal land on the remote host when the window is
SSH-connected, so the chat/files/terminal loop is complete in SSH mode before
the dashboard /api/terminal WebSocket exists.

- ssh-connection.cjs: buildInteractiveSshArgs() — `ssh -tt` over the EXISTING
  control master (no new auth handshake; attaches instantly), cd into the
  remote session cwd best-effort, then exec "$SHELL" -l. Pure + node --test
  covered (PTY flag, master reuse, cwd cd, quote-safety).
- main.cjs: hermes:terminal:start spawns node-pty wrapping that ssh command
  when activeSshTerminalTarget() returns the live SSH connection for the
  window's primary backend; otherwise the existing local-shell path is
  unchanged. Gated to SSH mode ONLY — token/oauth remotes return null and never
  get a remote shell (their trust boundary is a token, not shell access). The
  existing resize IPC already propagates: node-pty.resize() -> SIGWINCH -> ssh
  -> remote PTY, end to end. Remote cwd is NOT run through safeTerminalCwd
  (that stats the local fs).

Clearly marked TODO(remote-terminal): replace with /api/terminal over the
tunnel once specs/desktop-remote-terminal.md lands, so cwd-follows-session
becomes uniform and this interim path is deleted.

ssh-connection node --test: 26 pass. main.cjs node --check OK.
This commit is contained in:
yoniebans 2026-06-19 12:01:32 +02:00
parent 56cded1ae1
commit 7c21034330
3 changed files with 121 additions and 3 deletions

View file

@ -38,7 +38,7 @@ const { createLinkTitleWindow } = require('./link-title-window.cjs')
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
const { adoptServedDashboardToken } = require('./dashboard-token.cjs')
const { waitForDashboardPort } = require('./backend-ready.cjs')
const { SSH_ERROR, SshConnection, pickLocalPort, redactSecrets } = require('./ssh-connection.cjs')
const { SSH_ERROR, SshConnection, buildInteractiveSshArgs, pickLocalPort, redactSecrets } = require('./ssh-connection.cjs')
const remoteLifecycle = require('./remote-lifecycle.cjs')
const { collectSshConfigHosts, parseSshGOutput } = require('./ssh-config.cjs')
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
@ -4708,6 +4708,22 @@ async function teardownSshConnection(profile) {
}
}
// Resolve the live SSH connection backing the window's PRIMARY backend, or
// null when the active connection is not SSH. Used by the interim ssh -tt
// terminal so a remote terminal lands on the SSH host — and ONLY in SSH mode
// (it must never leak into token/oauth remotes, whose trust boundary is a
// token/cookie, not a shell credential).
function activeSshTerminalTarget() {
const scope = sshScopeKey(primaryProfileKey())
// Try the primary scope first, then the global scope (one of them backs the
// window depending on whether a per-profile SSH override is in play).
const state = sshConnections.get(scope) || sshConnections.get('')
if (!state || !state.ssh) {
return null
}
return state.ssh
}
// Bring up (or reuse) the SSH-tunneled dashboard for one scope and return a
// token-remote connection descriptor. `sshConfig` is the normalized
// { host, user?, port?, keyPath?, remoteHermesPath? }; `reuseToken` is the
@ -6641,10 +6657,54 @@ ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
ensureSpawnHelperExecutable()
const id = crypto.randomUUID()
const { args, command, name } = terminalShellCommand()
const cwd = safeTerminalCwd(payload?.cwd)
const cols = Math.max(2, Number.parseInt(String(payload?.cols || 80), 10) || 80)
const rows = Math.max(2, Number.parseInt(String(payload?.rows || 24), 10) || 24)
// INTERIM SSH-mode remote terminal (component 5; SSH mode ONLY). When the
// window's primary backend is an SSH connection, spawn node-pty wrapping
// `ssh -tt` over the EXISTING control master so the terminal lands on the
// remote host. node-pty's resize() sends SIGWINCH to the local ssh client,
// which forwards it to the remote PTY — so resize propagates end to end.
// The remote cwd is the (remote) session cwd; we do NOT run it through
// safeTerminalCwd (that stats the LOCAL fs). This never engages for
// token/oauth remotes (activeSshTerminalTarget returns null) — their trust
// boundary is a token, not a shell credential.
// TODO(remote-terminal): replace with the dashboard /api/terminal WebSocket
// once specs/desktop-remote-terminal.md lands; then the terminal rides the
// tunnel like every other socket and cwd-follows-session becomes uniform.
const sshTarget = activeSshTerminalTarget()
if (sshTarget) {
const remoteCwd = String(payload?.cwd || '').trim()
const sshArgs = buildInteractiveSshArgs(sshTarget, remoteCwd)
const sshPty = nodePty.spawn('ssh', sshArgs, {
cols,
cwd: app.getPath('home'),
env: terminalShellEnv(),
name: 'xterm-256color',
rows
})
terminalSessions.set(id, { pty: sshPty, webContentsId: event.sender.id })
const sshSend = (suffix, data) => {
if (event.sender.isDestroyed()) {
return
}
event.sender.send(terminalChannel(id, suffix), data)
}
sshPty.onData(data => sshSend('data', data))
sshPty.onExit(({ exitCode, signal }) => {
terminalSessions.delete(id)
sshSend('exit', { code: exitCode, signal: signal || null })
})
event.sender.once('destroyed', () => disposeTerminalSession(id))
return { cwd: remoteCwd, id, shell: 'ssh' }
}
const { args, command, name } = terminalShellCommand()
const cwd = safeTerminalCwd(payload?.cwd)
const ptyProcess = nodePty.spawn(command, args, {
cols,
cwd,

View file

@ -154,6 +154,38 @@ function buildMasterArgs(conn, connectTimeoutMs) {
]
}
// Interactive `ssh -tt` for the INTERIM remote terminal (component 5, SSH mode
// only). Reuses the existing ControlMaster socket so NO new auth handshake
// happens — the master is already open, so this attaches instantly and never
// prompts (BatchMode stays safe here for that reason). `-tt` forces a PTY even
// though our stdio is a node-pty, so the remote sees a real terminal.
//
// When a remoteCwd is given we cd into it (best-effort) then exec the user's
// login shell so the prompt/rc files load; an unreadable cwd falls back to
// $HOME rather than failing the session.
//
// NOTE (tracked): this is the interim path until the dashboard /api/terminal
// WebSocket lands (specs/desktop-remote-terminal.md). Once that ships, the
// terminal rides the tunnel like every other socket and cwd-follows-session
// behavior becomes uniform; delete this path then.
function buildInteractiveSshArgs(conn, remoteCwd, connectTimeoutMs) {
const args = [
'-tt',
...baseSshOptions(conn.controlPath, connectTimeoutMs),
...hostArgs(conn),
target(conn.user, conn.host)
]
const cwd = String(remoteCwd || '').trim()
if (cwd) {
// cd then exec a login shell; quote the path; tolerate a missing dir.
const q = `'${cwd.replace(/'/g, `'\\''`)}'`
args.push(`cd ${q} 2>/dev/null; exec "$SHELL" -l`)
} else {
args.push('exec "$SHELL" -l')
}
return args
}
// Local forward spec for `-O forward -L <local>:<remoteHost>:<remotePort>`.
// 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.
@ -438,6 +470,7 @@ module.exports = {
baseSshOptions,
buildControlArgs,
buildExecArgs,
buildInteractiveSshArgs,
buildMasterArgs,
classifySshError,
controlSocketPath,

View file

@ -19,6 +19,7 @@ const {
baseSshOptions,
buildControlArgs,
buildExecArgs,
buildInteractiveSshArgs,
buildMasterArgs,
classifySshError,
controlSocketPath,
@ -130,6 +131,30 @@ test('forwardSpec binds the local end to 127.0.0.1 only', () => {
assert.ok(!forwardSpec(5000, 6000).startsWith('0.0.0.0'))
})
test('buildInteractiveSshArgs requests a PTY, reuses the control master, execs a login shell', () => {
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
const args = buildInteractiveSshArgs(conn, '', 15000)
assert.equal(args[0], '-tt', 'forces a PTY so the remote sees a real terminal')
assert.ok(args.join(' ').includes('ControlPath=/tmp/x.sock'), 'reuses the existing master (no new auth)')
assert.equal(args[args.length - 2], 'me@box')
assert.equal(args[args.length - 1], 'exec "$SHELL" -l')
})
test('buildInteractiveSshArgs cds into the remote cwd (best-effort) before the shell', () => {
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
const args = buildInteractiveSshArgs(conn, '/home/me/project', 15000)
const remoteCmd = args[args.length - 1]
assert.match(remoteCmd, /^cd '\/home\/me\/project' 2>\/dev\/null; exec "\$SHELL" -l$/)
})
test('buildInteractiveSshArgs single-quotes a cwd with quotes safely', () => {
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
const args = buildInteractiveSshArgs(conn, "/tmp/a'b", 15000)
// the embedded quote must be escaped, not break out of the quoting
assert.ok(args[args.length - 1].startsWith("cd '/tmp/a'"))
assert.ok(args[args.length - 1].includes('exec "$SHELL" -l'))
})
// --- error classification ---------------------------------------------------
test('classifySshError detects a changed host key (fail-closed)', () => {