mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
56cded1ae1
commit
7c21034330
3 changed files with 121 additions and 3 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue