diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 8b47104d292..f65b0fdb4f1 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -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, diff --git a/apps/desktop/electron/ssh-connection.cjs b/apps/desktop/electron/ssh-connection.cjs index 9ed4c10f14e..8a42e45b101 100644 --- a/apps/desktop/electron/ssh-connection.cjs +++ b/apps/desktop/electron/ssh-connection.cjs @@ -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 ::`. // 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, diff --git a/apps/desktop/electron/ssh-connection.test.cjs b/apps/desktop/electron/ssh-connection.test.cjs index 86765566a34..5af349304db 100644 --- a/apps/desktop/electron/ssh-connection.test.cjs +++ b/apps/desktop/electron/ssh-connection.test.cjs @@ -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)', () => {