From 716bd5c773e59eb1e75667e5961315b66426dc3b Mon Sep 17 00:00:00 2001 From: HAN-oQo Date: Mon, 13 Jul 2026 18:23:30 +0900 Subject: [PATCH] feat: remote SSH session support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run Claude Code and shells on remote hosts over SSH. Remote hosts and directories are first-class projects alongside local ones — no local-only assumption anymore. - shell-profiles + remote-hosts: SSH shell profiles (mirrors the existing WSL wrapped-command pattern), ~/.ssh/config parsing, remote command assembly (single-quoted to survive ssh's argv re-parse), ControlMaster multiplexing. - main.js: open-terminal remote branch (skips local-path checks, local cwd=$HOME, no MCP/shim), plus IPC for remote projects, interactive connect, remote dir browse, host save/test, and write-to-~/.ssh/config. - session-cache: persisted remote projects injected into the sidebar; live remote sessions group under them (remote Claude reads as a Claude session). - renderer: Add Project (Local | Remote SSH) with "+ Add new host", inline Connect with structured password / host-key popups (no raw terminal), remote directory browser, host management in Settings, SSH badges. - 30 unit tests (node --test). Auth stays with the user: passwords/passphrases/passkeys/host-key confirmations are entered by the user; nothing is stored. Remote session indexing/search (Phase 2), fork/resume on the remote (Phase 3), and IDE-over-SSH (Phase 4) are deferred — see the PR description. Co-Authored-By: Claude Opus 4.8 (1M context) --- main.js | 327 +++++++++++++++++-- preload.js | 13 + public/dialogs.js | 663 ++++++++++++++++++++++++++++++++++++--- public/settings-panel.js | 149 +++++++++ public/sidebar.js | 24 +- public/style.css | 204 ++++++++++++ remote-hosts.js | 276 ++++++++++++++++ session-cache.js | 25 +- shell-profiles.js | 26 +- test/remote-ssh.test.js | 260 +++++++++++++++ 10 files changed, 1878 insertions(+), 89 deletions(-) create mode 100644 remote-hosts.js create mode 100644 test/remote-ssh.test.js diff --git a/main.js b/main.js index 2c587b77..c35e8e18 100644 --- a/main.js +++ b/main.js @@ -26,7 +26,8 @@ const cleanPtyEnv = Object.fromEntries( ); // Shell profiles → shell-profiles.js -const { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs } = require('./shell-profiles'); +const { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs, isSshProfile } = require('./shell-profiles'); +const remoteHosts = require('./remote-hosts'); const { startScheduler } = require('./schedule-runner'); const { encodeProjectPath } = require('./encode-project-path'); @@ -322,6 +323,15 @@ ipcMain.handle('add-project', (_event, projectPath) => { // --- IPC: remove-project --- ipcMain.handle('remove-project', (_event, projectPath) => { try { + // Remote projects live in settings only (no local .claude/projects folder). + if (typeof projectPath === 'string' && projectPath.startsWith('ssh://')) { + const global = getSetting('global') || {}; + global.remoteProjects = (global.remoteProjects || []).filter(p => p.projectPath !== projectPath); + setSetting('global', global); + deleteSetting('project:' + projectPath); + notifyRendererProjectsChanged(); + return { ok: true }; + } // Add to hidden projects list const global = getSetting('global') || {}; const hidden = global.hiddenProjects || []; @@ -807,6 +817,194 @@ ipcMain.handle('delete-setting', (_event, key) => { return { ok: true }; }); +// --- IPC: remote SSH hosts (Phase 1) --- + +// Merged list of remote targets: parsed from ~/.ssh/config plus user-defined +// manual hosts persisted in global settings under `remoteHosts`. +ipcMain.handle('get-remote-targets', () => { + const manual = (getSetting('global') || {}).remoteHosts || []; + return remoteHosts.loadRemoteHosts(manual); +}); + +// Persist the manual host list (config-derived hosts are never written back). +ipcMain.handle('save-remote-hosts', (_event, hosts) => { + const global = getSetting('global') || {}; + const clean = (Array.isArray(hosts) ? hosts : []) + .filter(h => h && h.host && String(h.host).trim()) + .map(h => ({ + id: h.id || undefined, + label: (h.label || '').trim() || undefined, + host: String(h.host).trim(), + user: (h.user || '').trim() || undefined, + port: h.port ? Number(h.port) : undefined, + identityFile: (h.identityFile || '').trim() || undefined, + options: h.options, + })) + .map(remoteHosts.normalizeManualHost); + global.remoteHosts = clean; + setSetting('global', global); + return { ok: true, hosts: remoteHosts.loadRemoteHosts(clean) }; +}); + +// Interactive Connect: authenticate/verify a host inline (not in the sidebar). +// Runs `ssh -tt true` in a PTY: after auth the trivial command +// exits (code 0 = connected) while ControlPersist keeps the connection warm for +// Browse/sessions. Prompts (password/passphrase/host key) are streamed to a small +// popup terminal in the renderer so the user answers there. +const _connectProcs = new Map(); +let _connectSeq = 0; + +ipcMain.handle('remote-connect-start', (_event, hostId) => { + const manual = (getSetting('global') || {}).remoteHosts || []; + const host = remoteHosts.findRemoteHost(hostId, manual); + if (!host) return { error: 'unknown host' }; + const sock = remoteHosts.controlSocketPath(os.tmpdir(), host.id); + const args = ['-tt', ...remoteHosts.controlArgs(sock), ...remoteHosts.hostTargetArgs(host), 'true']; + const connectId = ++_connectSeq; + let proc; + try { + proc = pty.spawn('ssh', args, { name: 'xterm-256color', cols: 80, rows: 16, cwd: os.homedir(), env: cleanPtyEnv }); + } catch (err) { + return { error: err.message }; + } + _connectProcs.set(connectId, proc); + const send = (channel, ...a) => { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send(channel, ...a); }; + proc.onData(d => send('remote-connect-data', connectId, d)); + proc.onExit(({ exitCode }) => { + _connectProcs.delete(connectId); + send('remote-connect-exit', connectId, exitCode); + }); + return { connectId }; +}); + +ipcMain.on('remote-connect-input', (_event, connectId, data) => { + const proc = _connectProcs.get(connectId); + if (proc) { try { proc.write(data); } catch {} } +}); + +ipcMain.handle('remote-connect-cancel', (_event, connectId) => { + const proc = _connectProcs.get(connectId); + if (proc) { try { proc.kill(); } catch {} } + _connectProcs.delete(connectId); + return { ok: true }; +}); + +// Append a host as a proper ~/.ssh/config entry (append-only, backed up once, +// skips if an entry with that alias already exists). +ipcMain.handle('write-ssh-config', (_event, host) => { + try { + if (!host || !host.host) return { error: 'host is required' }; + const h = remoteHosts.normalizeManualHost(host); + const cfgPath = remoteHosts.defaultSshConfigPath(); + const dir = path.dirname(cfgPath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + const existing = fs.existsSync(cfgPath) ? fs.readFileSync(cfgPath, 'utf8') : ''; + // Dedup: does a Host line already list this alias as a whole word? + const aliasRe = new RegExp('^\\s*Host\\s+(.*\\s)?' + h.label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(\\s.*)?$', 'mi'); + if (aliasRe.test(existing)) return { error: `Host "${h.label}" already exists in ~/.ssh/config` }; + // One-time backup before the first Switchboard-written change. + const bak = cfgPath + '.switchboard.bak'; + if (existing && !fs.existsSync(bak)) fs.writeFileSync(bak, existing, { mode: 0o600 }); + const block = (existing && !existing.endsWith('\n') ? '\n' : '') + '\n# added by Switchboard\n' + remoteHosts.buildSshConfigEntry(h) + '\n'; + fs.appendFileSync(cfgPath, block, { mode: 0o600 }); + notifyRendererProjectsChanged(); + return { ok: true, path: cfgPath, label: h.label }; + } catch (err) { + return { error: err.message }; + } +}); + +// Non-interactive connectivity probe (key/agent auth only; never prompts). +ipcMain.handle('test-remote-host', (_event, hostId) => { + const manual = (getSetting('global') || {}).remoteHosts || []; + const host = remoteHosts.findRemoteHost(hostId, manual); + if (!host) return Promise.resolve({ ok: false, error: 'unknown host' }); + return new Promise((resolve) => { + let out = ''; + let done = false; + const finish = (result) => { if (!done) { done = true; resolve(result); } }; + let proc; + try { + proc = pty.spawn('ssh', remoteHosts.testConnectionArgs(host), { + name: 'xterm-256color', cols: 80, rows: 24, cwd: os.homedir(), env: cleanPtyEnv, + }); + } catch (err) { + return finish({ ok: false, error: err.message }); + } + const killTimer = setTimeout(() => { + try { proc.kill(); } catch {} + finish({ ok: false, status: 'unreachable', reachable: false, message: 'Timed out', output: out.trim() }); + }, 15000); + proc.onData(d => { out += d; if (out.length > 4000) out = out.slice(-4000); }); + proc.onExit(({ exitCode }) => { + clearTimeout(killTimer); + // BatchMode never prompts, so a password-auth host "fails" but is reachable. + // Classify the output so the UI reports reachability honestly. + const cls = remoteHosts.classifyConnResult(exitCode, out); + finish({ ok: cls.reachable, exitCode, ...cls, output: out.trim() }); + }); + }); +}); + +// --- IPC: remote projects (Model A — a project can be local or remote) --- + +// Register a remote project (host + remote directory) that appears in the +// sidebar alongside local projects. Persisted in global settings. +ipcMain.handle('add-remote-project', (_event, { hostId, remotePath } = {}) => { + try { + const global = getSetting('global') || {}; + const manual = global.remoteHosts || []; + const host = remoteHosts.findRemoteHost(hostId, manual); + if (!host) return { error: 'unknown host' }; + const dir = (remotePath && String(remotePath).trim()) ? String(remotePath).trim() : '~'; + const projectPath = remoteHosts.remoteProjectPath(host.label, dir); + const list = global.remoteProjects || []; + if (!list.some(p => p.projectPath === projectPath)) { + list.push({ projectPath, hostId: host.id, hostLabel: host.label, remotePath: dir }); + global.remoteProjects = list; + setSetting('global', global); + } + notifyRendererProjectsChanged(); + return { ok: true, projectPath, hostId: host.id, hostLabel: host.label, remotePath: dir }; + } catch (err) { + return { error: err.message }; + } +}); + +// List directories at a remote path (for the remote directory browser). Uses the +// shared control socket (BatchMode = never prompts). If the host needs interactive +// auth and has no live connection yet, this fails fast with needsAuth so the UI can +// tell the user to open a session first (which authenticates and persists the master). +ipcMain.handle('remote-browse', (_event, { hostId, path: remotePath } = {}) => { + const manual = (getSetting('global') || {}).remoteHosts || []; + const host = remoteHosts.findRemoteHost(hostId, manual); + if (!host) return Promise.resolve({ ok: false, error: 'unknown host' }); + const dir = (remotePath && String(remotePath).trim()) ? String(remotePath).trim() : '~'; + const sock = remoteHosts.controlSocketPath(os.tmpdir(), host.id); + const args = remoteHosts.browseArgs(host, sock, dir); + return new Promise((resolve) => { + let out = '', err = '', done = false; + const finish = (r) => { if (!done) { done = true; resolve(r); } }; + let proc; + try { + proc = pty.spawn('ssh', args, { name: 'xterm-256color', cols: 80, rows: 24, cwd: os.homedir(), env: cleanPtyEnv }); + } catch (e) { + return finish({ ok: false, error: e.message }); + } + const killTimer = setTimeout(() => { try { proc.kill(); } catch {} finish({ ok: false, error: 'timed out', path: dir }); }, 12000); + // pty merges stdout/stderr; classify on non-zero exit. + proc.onData(d => { out += d; if (out.length > 20000) out = out.slice(-20000); }); + proc.onExit(({ exitCode }) => { + clearTimeout(killTimer); + if (exitCode === 0) { + return finish({ ok: true, path: dir, dirs: remoteHosts.parseLsDirs(out) }); + } + const cls = remoteHosts.classifyConnResult(exitCode, out); + finish({ ok: false, path: dir, needsAuth: cls.reachable && cls.status !== 'unreachable', status: cls.status, message: cls.message, output: out.trim().slice(-500) }); + }); + }); +}); + // --- Scheduled tasks --- const scheduleIpc = require('./schedule-ipc'); @@ -942,45 +1140,70 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se return { ok: true, reattached: true, mcpActive: !!session.mcpServer }; } - // Spawn new PTY - if (!fs.existsSync(projectPath)) { - return { ok: false, error: `project directory no longer exists: ${projectPath}` }; + const isPlainTerminal = sessionOptions?.type === 'terminal'; + + // Remote (SSH) session? Resolve the host + ssh profile up front so we can skip + // local-filesystem checks below. Remote sessions run over `ssh` and their + // project directory lives on the remote host, not locally. + const remoteHostId = sessionOptions?.remoteHostId || null; + let remoteHost = null; + if (remoteHostId) { + const manual = (getSetting('global') || {}).remoteHosts || []; + remoteHost = remoteHosts.findRemoteHost(remoteHostId, manual); + if (!remoteHost) return { ok: false, error: `unknown remote host: ${remoteHostId}` }; } + const isRemote = !!remoteHost; - const isPlainTerminal = sessionOptions?.type === 'terminal'; + // Spawn new PTY. Local sessions require an existing project directory; remote + // sessions do not (the directory is on the remote host). + if (!isRemote && !fs.existsSync(projectPath)) { + return { ok: false, error: `project directory no longer exists: ${projectPath}` }; + } - // Resolve shell profile from effective settings - const effectiveProfileId = (() => { - const global = getSetting('global') || {}; - const project = projectPath ? (getSetting('project:' + projectPath) || {}) : {}; - let profileId = SETTING_DEFAULTS.shellProfile; - if (global.shellProfile !== undefined && global.shellProfile !== null) profileId = global.shellProfile; - if (project.shellProfile !== undefined && project.shellProfile !== null) profileId = project.shellProfile; - return profileId; - })(); - // WSL profiles only work for plain terminals — Claude CLI sessions need the - // Windows shell because session data lives on the Windows filesystem. - const requestedProfile = resolveShell(effectiveProfileId); - const useWslProfile = isWslShell(requestedProfile.path) && isPlainTerminal; - const shellProfile = (isWslShell(requestedProfile.path) && !isPlainTerminal) - ? resolveShell('auto') - : requestedProfile; - const shell = shellProfile.path; - const shellExtraArgs = [...(shellProfile.args || [])]; - const isWsl = isWslShell(shell); - // For WSL, convert Windows path to /mnt/ path and pass via --cd; - // the spawn cwd must remain a valid Windows path for wsl.exe itself. - if (isWsl) { - const wslCwd = windowsToWslPath(projectPath); - shellExtraArgs.unshift('--cd', wslCwd); + // Resolve the shell to spawn. + let shell, shellExtraArgs, isWsl; + if (isRemote) { + shell = 'ssh'; + // -t (PTY) + connection multiplexing (shared control socket) + target. The + // control socket lets the directory browser reuse this authenticated + // connection, so the user only authenticates once per host. + const sock = remoteHosts.controlSocketPath(os.tmpdir(), remoteHost.id); + shellExtraArgs = ['-t', ...remoteHosts.controlArgs(sock), ...remoteHosts.hostTargetArgs(remoteHost)]; + isWsl = false; + log.info(`[shell] remote host=${remoteHost.id} shell=ssh args=${JSON.stringify(shellExtraArgs)}`); + } else { + // Resolve shell profile from effective settings + const effectiveProfileId = (() => { + const global = getSetting('global') || {}; + const project = projectPath ? (getSetting('project:' + projectPath) || {}) : {}; + let profileId = SETTING_DEFAULTS.shellProfile; + if (global.shellProfile !== undefined && global.shellProfile !== null) profileId = global.shellProfile; + if (project.shellProfile !== undefined && project.shellProfile !== null) profileId = project.shellProfile; + return profileId; + })(); + // WSL profiles only work for plain terminals — Claude CLI sessions need the + // Windows shell because session data lives on the Windows filesystem. + const requestedProfile = resolveShell(effectiveProfileId); + const shellProfile = (isWslShell(requestedProfile.path) && !isPlainTerminal) + ? resolveShell('auto') + : requestedProfile; + shell = shellProfile.path; + shellExtraArgs = [...(shellProfile.args || [])]; + isWsl = isWslShell(shell); + // For WSL, convert Windows path to /mnt/ path and pass via --cd; + // the spawn cwd must remain a valid Windows path for wsl.exe itself. + if (isWsl) { + const wslCwd = windowsToWslPath(projectPath); + shellExtraArgs.unshift('--cd', wslCwd); + } + log.info(`[shell] profile=${shellProfile.id} shell=${shell} args=${JSON.stringify(shellExtraArgs)}`); } - log.info(`[shell] profile=${shellProfile.id} shell=${shell} args=${JSON.stringify(shellExtraArgs)}`); let knownJsonlFiles = new Set(); let sessionSlug = null; let projectFolder = null; - if (!isPlainTerminal) { + if (!isPlainTerminal && !isRemote) { // Snapshot existing .jsonl files before spawning (for new session + fork/plan detection) projectFolder = encodeProjectPath(projectPath); const claudeProjectDir = path.join(PROJECTS_DIR, projectFolder); @@ -1009,7 +1232,40 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se let ptyProcess; let mcpServer = null; try { - if (isPlainTerminal) { + if (isRemote) { + // Remote (SSH) session: run Claude (or a login shell) on the remote host. + // No claude shim and no IDE/MCP emulation — the MCP socket is local and + // the CLI on the remote can't reach it (IDE over SSH is a later phase). + const remoteMode = sessionOptions?.remoteMode === 'shell' ? 'shell' : 'claude'; + let innerCmd = null; + if (remoteMode === 'claude') { + // Phase 1 launches a fresh remote Claude session; --session-id/--resume + // are managed on the remote host and wired up in a later milestone. + let cc = 'claude'; + if (sessionOptions?.dangerouslySkipPermissions) { + cc += ' --dangerously-skip-permissions'; + } else if (sessionOptions?.permissionMode) { + cc += ` --permission-mode "${sessionOptions.permissionMode}"`; + } + if (sessionOptions?.addDirs) { + const dirs = sessionOptions.addDirs.split(',').map(d => d.trim()).filter(Boolean); + for (const dir of dirs) cc += ` --add-dir "${dir}"`; + } + innerCmd = cc; + } + const remoteCmd = remoteHosts.buildRemoteCommand(remoteMode, sessionOptions?.remoteDir || '~', innerCmd); + ptyProcess = pty.spawn(shell, shellArgs(shell, remoteCmd, shellExtraArgs), { + name: 'xterm-256color', + cols: 120, + rows: 30, + cwd: os.homedir(), // local cwd; the remote cd happens inside remoteCmd + env: { + ...cleanPtyEnv, + TERM: 'xterm-256color', COLORTERM: 'truecolor', + TERM_PROGRAM: 'iTerm.app', TERM_PROGRAM_VERSION: '3.6.6', FORCE_COLOR: '3', ITERM_SESSION_ID: '1', + }, + }); + } else if (isPlainTerminal) { // Plain terminal: interactive login shell, no claude command // Inject a shell function to override `claude` with a helpful message const claudeShim = 'claude() { echo "\\033[33mTo start a Claude session, use the + button in the sidebar.\\033[0m"; return 1; }; export -f claude 2>/dev/null;'; @@ -1121,7 +1377,12 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se outputBuffer: [], outputBufferSize: 0, altScreen: false, projectPath, firstResize: true, projectFolder, knownJsonlFiles, sessionSlug, - isPlainTerminal, forkFrom: sessionOptions?.forkFrom || null, + // Remote sessions are treated as live "terminal" entries so they flow through + // the sidebar's active-session injection (no local .jsonl indexing yet). + isPlainTerminal: isPlainTerminal || isRemote, + remote: isRemote, remoteHost: remoteHost || null, + remoteMode: isRemote ? (sessionOptions?.remoteMode === 'shell' ? 'shell' : 'claude') : null, + forkFrom: sessionOptions?.forkFrom || null, mcpServer, _openedAt: Date.now(), }; activeSessions.set(sessionId, session); diff --git a/preload.js b/preload.js index 91d8b5e5..bec6be52 100644 --- a/preload.js +++ b/preload.js @@ -32,6 +32,19 @@ contextBridge.exposeInMainWorld('api', { runScheduleNow: (filePath) => ipcRenderer.invoke('run-schedule-now', filePath), getShellProfiles: () => ipcRenderer.invoke('get-shell-profiles'), + // Remote SSH hosts + projects + getRemoteTargets: () => ipcRenderer.invoke('get-remote-targets'), + saveRemoteHosts: (hosts) => ipcRenderer.invoke('save-remote-hosts', hosts), + testRemoteHost: (hostId) => ipcRenderer.invoke('test-remote-host', hostId), + addRemoteProject: (opts) => ipcRenderer.invoke('add-remote-project', opts), + remoteBrowse: (opts) => ipcRenderer.invoke('remote-browse', opts), + writeSshConfig: (host) => ipcRenderer.invoke('write-ssh-config', host), + remoteConnectStart: (hostId) => ipcRenderer.invoke('remote-connect-start', hostId), + remoteConnectInput: (connectId, data) => ipcRenderer.send('remote-connect-input', connectId, data), + remoteConnectCancel: (connectId) => ipcRenderer.invoke('remote-connect-cancel', connectId), + onRemoteConnectData: (callback) => ipcRenderer.on('remote-connect-data', (_e, id, data) => callback(id, data)), + onRemoteConnectExit: (callback) => ipcRenderer.on('remote-connect-exit', (_e, id, code) => callback(id, code)), + browseFolder: () => ipcRenderer.invoke('browse-folder'), addProject: (projectPath) => ipcRenderer.invoke('add-project', projectPath), removeProject: (projectPath) => ipcRenderer.invoke('remove-project', projectPath), diff --git a/public/dialogs.js b/public/dialogs.js index 433d61cc..b3c25479 100644 --- a/public/dialogs.js +++ b/public/dialogs.js @@ -98,11 +98,38 @@ function showNewSessionPopover(project, anchorEl) { termBtn.innerHTML = ' Terminal'; termBtn.onclick = () => { popover.remove(); launchTerminalSession(project); }; + // Remote project: reuse the exact same icons as the local entries (identical + // Claude logo), just labeled/wired for running on the project's SSH host. + if (project.remote) { + const rHost = { id: project.hostId, label: project.hostLabel }; + const claudeR = document.createElement('button'); + claudeR.className = 'popover-option'; + claudeR.innerHTML = claudeBtn.innerHTML.replace(' Claude', ' Claude (remote)'); + claudeR.onclick = () => { popover.remove(); launchRemoteSession(rHost, { remoteMode: 'claude', remoteDir: project.remotePath }); }; + const claudeROpts = document.createElement('button'); + claudeROpts.className = 'popover-option'; + claudeROpts.innerHTML = claudeBtn.innerHTML.replace(' Claude', ' Claude (remote, Configure…)'); + claudeROpts.onclick = () => { popover.remove(); showNewSessionDialog(project); }; + const shellR = document.createElement('button'); + shellR.className = 'popover-option popover-option-terminal'; + shellR.innerHTML = termBtn.innerHTML.replace(' Terminal', ' Shell (remote)'); + shellR.onclick = () => { popover.remove(); launchRemoteSession(rHost, { remoteMode: 'shell', remoteDir: project.remotePath }); }; + popover.appendChild(claudeR); + popover.appendChild(claudeROpts); + popover.appendChild(shellR); + positionAndBindPopover(popover, anchorEl); + return; + } + popover.appendChild(claudeBtn); popover.appendChild(claudeOptsBtn); popover.appendChild(termBtn); - // Position relative to anchor, flip upward if it would overflow + positionAndBindPopover(popover, anchorEl); +} + +// Position a popover under its anchor (flip up on overflow) and close on outside click. +function positionAndBindPopover(popover, anchorEl) { document.body.appendChild(popover); const rect = anchorEl.getBoundingClientRect(); const popoverHeight = popover.offsetHeight; @@ -113,7 +140,6 @@ function showNewSessionPopover(project, anchorEl) { } popover.style.left = rect.left + 'px'; - // Close on click outside function onClickOutside(e) { if (!popover.contains(e.target) && e.target !== anchorEl) { popover.remove(); @@ -169,8 +195,71 @@ async function launchTerminalSession(project) { pollActiveSessions(); } +// Launch a session on a remote host over SSH. Modeled on launchTerminalSession: +// remote sessions are live "terminal" entries with a synthetic ssh:// project +// path so they group in the sidebar (no local .jsonl indexing in Phase 1). +async function launchRemoteSession(host, opts) { + const options = opts || {}; + const remoteDir = options.remoteDir || '~'; + const mode = options.remoteMode === 'shell' ? 'shell' : 'claude'; + const sessionId = crypto.randomUUID(); + const projectPath = `ssh://${host.label}/${remoteDir}`; + const summary = (mode === 'shell' ? 'Shell @ ' : 'Claude @ ') + host.label; + const session = { + sessionId, + summary, + firstPrompt: '', + projectPath, + name: null, + starred: 0, + archived: 0, + messageCount: 0, + modified: new Date().toISOString(), + created: new Date().toISOString(), + type: 'terminal', + remote: true, + remoteLabel: host.label, + remoteMode: mode, + }; + + const folder = encodeProjectPath(projectPath); + pendingSessions.set(sessionId, { session, projectPath, folder }); + + sessionMap.set(sessionId, session); + for (const projList of [cachedProjects, cachedAllProjects]) { + let proj = projList.find(p => p.projectPath === projectPath); + if (!proj) { + proj = { folder, projectPath, sessions: [] }; + projList.unshift(proj); + } + proj.sessions.unshift(session); + } + refreshSidebar(); + + const entry = createTerminalEntry(session); + + const result = await window.api.openTerminal(sessionId, projectPath, true, { + type: 'terminal', + remoteHostId: host.id, + remoteMode: mode, + remoteDir, + dangerouslySkipPermissions: options.dangerouslySkipPermissions, + permissionMode: options.permissionMode, + addDirs: options.addDirs, + }); + if (!result.ok) { + entry.terminal.write(`\r\nError: ${result.error}\r\n`); + entry.closed = true; + return; + } + + showSession(sessionId); + pollActiveSessions(); +} + async function showNewSessionDialog(project) { const effective = await window.api.getEffectiveSettings(project.projectPath); + const isRemote = !!project.remote; // Model A: the project itself is local or remote const overlay = document.createElement('div'); overlay.className = 'new-session-overlay'; @@ -197,38 +286,54 @@ async function showNewSessionDialog(project) { ``; } + const titleText = isRemote + ? `New Remote Session — ${escapeHtml(project.hostLabel)} : ${escapeHtml(project.remotePath || '~')}` + : `New Session — ${escapeHtml(project.projectPath.split('/').filter(Boolean).slice(-2).join('/'))}`; + dialog.innerHTML = ` -

New Session — ${escapeHtml(project.projectPath.split('/').filter(Boolean).slice(-2).join('/'))}

-
-
Permission Mode
-
${renderModeGrid()}
-
-
+

${titleText}

+
- Worktree -
Run session in an isolated git worktree
+ Remote Directory +
Working directory on the remote host
-
- - +
+ +
-
- Chrome -
Enable Chrome browser automation
-
-
- -
+
Permission Mode
+
${renderModeGrid()}
-
-
- Pre-launch Command -
Prepended to the claude command
+
+
+
+ Worktree +
Run session in an isolated git worktree
+
+
+ + +
-
- +
+
+ Chrome +
Enable Chrome browser automation
+
+
+ +
+
+
+
+ Pre-launch Command +
Prepended to the claude command
+
+
+ +
@@ -242,6 +347,7 @@ async function showNewSessionDialog(project) {
+
`; @@ -269,13 +375,43 @@ async function showNewSessionDialog(project) { overlay.remove(); } - function start() { + // Model A: local vs remote is fixed by the project. Show the matching controls. + const localOnly = dialog.querySelector('#nsd-local-only'); + const shellBtn = dialog.querySelector('.new-session-shell-btn'); + const startBtn = dialog.querySelector('.new-session-start-btn'); + if (localOnly) localOnly.style.display = isRemote ? 'none' : ''; + shellBtn.style.display = isRemote ? '' : 'none'; + startBtn.textContent = isRemote ? 'Start Claude' : 'Start'; + + if (isRemote) { + const rbBtn = dialog.querySelector('#nsd-remote-browse'); + if (rbBtn) rbBtn.onclick = async () => { + const inp = dialog.querySelector('#nsd-remote-dir'); + const picked = await showRemoteDirBrowser({ id: project.hostId, label: project.hostLabel }, inp.value.trim() || '~'); + if (picked) inp.value = picked; + }; + } + + function permissionOptions() { const options = {}; - if (dangerousSkip) { - options.dangerouslySkipPermissions = true; - } else if (selectedMode) { - options.permissionMode = selectedMode; + if (dangerousSkip) options.dangerouslySkipPermissions = true; + else if (selectedMode) options.permissionMode = selectedMode; + options.addDirs = dialog.querySelector('#nsd-add-dirs').value.trim(); + return options; + } + + function remoteDirValue() { + return dialog.querySelector('#nsd-remote-dir').value.trim() || project.remotePath || '~'; + } + + function start() { + if (isRemote) { + const options = { ...permissionOptions(), remoteMode: 'claude', remoteDir: remoteDirValue() }; + close(); + launchRemoteSession({ id: project.hostId, label: project.hostLabel }, options); + return; } + const options = permissionOptions(); if (dialog.querySelector('#nsd-worktree').checked) { options.worktree = true; options.worktreeName = dialog.querySelector('#nsd-worktree-name').value.trim(); @@ -285,14 +421,21 @@ async function showNewSessionDialog(project) { } const preLaunch = dialog.querySelector('#nsd-pre-launch').value.trim(); if (preLaunch) options.preLaunchCmd = preLaunch; - options.addDirs = dialog.querySelector('#nsd-add-dirs').value.trim(); if (effective.mcpEmulation === false) options.mcpEmulation = false; close(); launchNewSession(project, options); } + function openShell() { + if (!isRemote) return; + const options = { remoteMode: 'shell', remoteDir: remoteDirValue() }; + close(); + launchRemoteSession({ id: project.hostId, label: project.hostLabel }, options); + } + dialog.querySelector('.new-session-cancel-btn').onclick = close; - dialog.querySelector('.new-session-start-btn').onclick = start; + startBtn.onclick = start; + shellBtn.onclick = openShell; overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); // Keyboard support @@ -427,19 +570,367 @@ async function showResumeSessionDialog(session) { // Settings viewer is in settings-panel.js (openSettingsViewer / closeSettingsViewer) // Global settings button & add project button bindings are in app.js (need DOM refs) -function showAddProjectDialog() { +// Dedicated interactive-connect channel. One connect runs at a time; the module +// listeners route events to the active handler set by connectRemoteHost(). +let _rcData = null, _rcExit = null; +if (window.api && window.api.onRemoteConnectData) window.api.onRemoteConnectData((id, data) => { if (_rcData) _rcData(id, data); }); +if (window.api && window.api.onRemoteConnectExit) window.api.onRemoteConnectExit((id, code) => { if (_rcExit) _rcExit(id, code); }); + +// Structured auth prompt. Presents the right UI for what ssh is asking: +// - hostkey → fingerprint text + Yes/No +// - password/passphrase → masked input field +// - otp/generic → text field +// Resolves to the string to send (for hostkey: 'yes'), or null if cancelled. +function showAuthPrompt(host, p) { + return new Promise((resolve) => { + const overlay = document.createElement('div'); + overlay.className = 'new-session-overlay remote-browser-overlay'; + const d = document.createElement('div'); + d.className = 'new-session-dialog'; + const titles = { password: 'Password', passphrase: 'Key passphrase', otp: 'Verification code', hostkey: 'Verify host key', generic: 'Input required' }; + const title = titles[p.kind] || 'Input required'; + const done = (v) => { overlay.remove(); document.removeEventListener('keydown', onKey); resolve(v); }; + function onKey(e) { if (e.key === 'Escape') done(null); } + + if (p.kind === 'hostkey') { + d.innerHTML = ` +

${title} — ${escapeHtml(host.label || host.id)}

+
First time connecting to this host. Confirm the fingerprint to continue.
+
${escapeHtml(p.text || '')}
+
+ + +
`; + overlay.appendChild(d); document.body.appendChild(overlay); + document.addEventListener('keydown', onKey); + overlay.addEventListener('click', (e) => { if (e.target === overlay) done(null); }); + d.querySelector('#ap-yes').onclick = () => done('yes'); + d.querySelector('#ap-no').onclick = () => done(null); + return; + } + + const masked = (p.kind === 'password' || p.kind === 'passphrase'); + d.innerHTML = ` +

${title} — ${escapeHtml(host.label || host.id)}

+
${escapeHtml(p.text || (title + ':'))}
+
+ +
+
+ + +
`; + overlay.appendChild(d); document.body.appendChild(overlay); + const inp = d.querySelector('#ap-input'); + inp.focus(); + // Submit sends the value to ssh, then clears it from the field immediately. + const submit = () => { const v = inp.value; inp.value = ''; done(v); }; + document.addEventListener('keydown', onKey); + inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); submit(); } }); + overlay.addEventListener('click', (e) => { if (e.target === overlay) done(null); }); + d.querySelector('#ap-ok').onclick = submit; + d.querySelector('#ap-cancel').onclick = () => done(null); + }); +} + +// Verify/authenticate a host inline (no sidebar session). Resolves true once the +// connection is established (and warmed via ControlMaster). ssh's prompts are +// surfaced as structured popups (password field / Yes-No fingerprint) — never a +// raw terminal. Key/agent hosts connect silently with no popup. +function connectRemoteHost(host) { + return new Promise((resolve) => { + let connectId = null, buffer = '', settled = false, promptOpen = false, errModal = null; + + function finish(val) { + if (settled) return; + settled = true; _rcData = null; _rcExit = null; + if (errModal) { errModal.remove(); errModal = null; } + resolve(val); + } + + // Detect what ssh is waiting for. Only when the buffer ends WITHOUT a newline + // (cursor parked at a prompt), which avoids matching normal banner lines. + function detectPrompt(buf) { + const tail = buf.slice(-1000); + if (/\n[ \t]*$/.test(tail)) return null; // ends with newline → not a waiting prompt + if (/(authenticity of host|continue connecting \(yes\/no)/i.test(tail) && /\?[ \t]*$/.test(tail)) { + const m = tail.match(/The authenticity of host[\s\S]*\?[ \t]*$/i); + return { kind: 'hostkey', text: (m ? m[0] : tail).trim() }; + } + if (/enter passphrase for key[^\n]*:[ \t]*$/i.test(tail)) return { kind: 'passphrase', text: tail.split('\n').pop().trim() }; + if (/password:[ \t]*$/i.test(tail)) return { kind: 'password', text: tail.split('\n').pop().trim() }; + if (/(verification code|one-time|two-factor|token|otp)[^\n]*:[ \t]*$/i.test(tail)) return { kind: 'otp', text: tail.split('\n').pop().trim() }; + if (/[:?][ \t]*$/.test(tail)) { + const line = tail.split('\n').pop().trim(); + if (line.length > 1 && line.length < 200) return { kind: 'generic', text: line }; + } + return null; + } + + async function handlePrompt(p) { + promptOpen = true; + const ans = await showAuthPrompt(host, p); + promptOpen = false; + if (ans === null) { if (connectId != null) window.api.remoteConnectCancel(connectId); finish(false); return; } + buffer = ''; + if (connectId != null) window.api.remoteConnectInput(connectId, ans + '\n'); + } + + function showError(msg) { + if (settled) return; + if (errModal) errModal.remove(); + errModal = document.createElement('div'); + errModal.className = 'new-session-overlay remote-browser-overlay'; + const d = document.createElement('div'); + d.className = 'new-session-dialog'; + d.innerHTML = ` +

Connect — ${escapeHtml(host.label || host.id)}

+
${escapeHtml(msg)}
+
+ + +
`; + errModal.appendChild(d); document.body.appendChild(errModal); + d.querySelector('#ce-cancel').onclick = () => finish(false); + d.querySelector('#ce-retry').onclick = () => { errModal.remove(); errModal = null; start(); }; + } + + async function start() { + buffer = ''; + const res = await window.api.remoteConnectStart(host.id); + if (!res || res.error) { showError((res && res.error) || 'Could not start ssh.'); return; } + connectId = res.connectId; + _rcData = (id, data) => { + if (id !== connectId) return; + buffer += data; if (buffer.length > 8000) buffer = buffer.slice(-8000); + if (promptOpen) return; + const p = detectPrompt(buffer); + if (p) handlePrompt(p); + }; + _rcExit = (id, code) => { + if (id !== connectId) return; + _rcData = null; _rcExit = null; + if (code === 0) finish(true); + else { + const lastLine = buffer.split('\n').map(s => s.trim()).filter(Boolean).slice(-1)[0] || ''; + showError('Connection failed' + (lastLine ? ': ' + lastLine : ' (exit ' + code + ').')); + } + }; + } + + start(); + }); +} + +// Remote directory browser: navigate the remote filesystem over SSH and pick a +// directory (like the local folder Browse). Resolves to the chosen path or null. +function showRemoteDirBrowser(host, startPath) { + return new Promise((resolve) => { + const overlay = document.createElement('div'); + overlay.className = 'new-session-overlay remote-browser-overlay'; + const dialog = document.createElement('div'); + dialog.className = 'new-session-dialog remote-browser-dialog'; + dialog.innerHTML = ` +

Browse — ${escapeHtml(host.label || host.id)}

+
+ + +
+
+
+
+ + +
`; + overlay.appendChild(dialog); + document.body.appendChild(overlay); + + const pathInput = dialog.querySelector('#rb-path'); + const listEl = dialog.querySelector('#rb-list'); + const msgEl = dialog.querySelector('#rb-msg'); + let current = startPath || '~'; + + function close(val) { overlay.remove(); document.removeEventListener('keydown', onKey); resolve(val); } + function onKey(e) { if (e.key === 'Escape') close(null); } + document.addEventListener('keydown', onKey); + + function joinPath(base, name) { return base === '~' ? '~/' + name : base.replace(/\/+$/, '') + '/' + name; } + function parentPath(p) { + if (p === '~' || p === '/') return p; + const t = p.replace(/\/+$/, ''); + const i = t.lastIndexOf('/'); + if (i <= 0) return p.startsWith('/') ? '/' : '~'; + const par = t.slice(0, i); + return par === '~' ? '~' : (par || '/'); + } + + async function load(path) { + current = path; + pathInput.value = path; + msgEl.textContent = ''; + listEl.innerHTML = '
Loading…
'; + let res; + try { res = await window.api.remoteBrowse({ hostId: host.id, path }); } + catch (e) { listEl.innerHTML = ''; msgEl.textContent = 'Error: ' + e.message; return; } + listEl.innerHTML = ''; + if (!res.ok) { + if (res.needsAuth) { + msgEl.textContent = 'This host needs interactive login. Open a session to it once to authenticate, then try Browse again — or just type the path.'; + } else { + msgEl.textContent = res.message || res.error || 'Could not list this directory.'; + } + return; + } + const up = document.createElement('div'); + up.className = 'remote-browser-item remote-browser-up'; + up.textContent = '📂 ..'; + up.onclick = () => load(parentPath(current)); + listEl.appendChild(up); + for (const d of res.dirs) { + const it = document.createElement('div'); + it.className = 'remote-browser-item'; + it.textContent = '📁 ' + d; + it.onclick = () => load(joinPath(current, d)); + listEl.appendChild(it); + } + if (!res.dirs.length) { + const e = document.createElement('div'); + e.className = 'remote-browser-status'; + e.textContent = '(no subdirectories)'; + listEl.appendChild(e); + } + } + + dialog.querySelector('#rb-go').onclick = () => load(pathInput.value.trim() || '~'); + pathInput.addEventListener('keydown', e => { if (e.key === 'Enter') { e.stopPropagation(); load(pathInput.value.trim() || '~'); } }); + dialog.querySelector('#rb-cancel').onclick = () => close(null); + dialog.querySelector('#rb-select').onclick = () => close(pathInput.value.trim() || current); + overlay.addEventListener('click', e => { if (e.target === overlay) close(null); }); + + load(current); + }); +} + +// Inline "add SSH host" form. Persists the new host into settings (merged with +// existing manual hosts) and resolves to the saved host ({id,label,…}) or null. +function showAddHostDialog() { + return new Promise((resolve) => { + const overlay = document.createElement('div'); + overlay.className = 'new-session-overlay remote-browser-overlay'; + const d = document.createElement('div'); + d.className = 'new-session-dialog'; + d.innerHTML = ` +

Add SSH Host

+
Label
+
User
+
Host
+
Port
+
Identity file
+
+
+ Extra options +
Extra ssh -o options for legacy/special hosts — leave blank for most. Click to add:
+
+ + + + + +
+
+
+
+
+
+ + +
`; + overlay.appendChild(d); + document.body.appendChild(overlay); + d.querySelector('#ah-host').focus(); + + function close(v) { overlay.remove(); document.removeEventListener('keydown', onKey); resolve(v); } + function onKey(e) { if (e.key === 'Escape') close(null); } + document.addEventListener('keydown', onKey); + const setErr = (m) => { const e = d.querySelector('#ah-err'); e.textContent = m; e.className = 'remote-connect-msg err'; }; + + d.querySelector('#ah-cancel').onclick = () => close(null); + overlay.addEventListener('click', (e) => { if (e.target === overlay) close(null); }); + // Example-option chips: click to append (deduped) into the options field. + d.querySelectorAll('.ah-chip').forEach(chip => { + chip.onclick = () => { + const inp = d.querySelector('#ah-options'); + const parts = inp.value.split(',').map(s => s.trim()).filter(Boolean); + if (!parts.includes(chip.dataset.opt)) parts.push(chip.dataset.opt); + inp.value = parts.join(', '); + }; + }); + d.querySelector('#ah-save').onclick = async () => { + const host = d.querySelector('#ah-host').value.trim(); + if (!host) { setErr('Host is required.'); return; } + const newHost = { + label: d.querySelector('#ah-label').value.trim(), + user: d.querySelector('#ah-user').value.trim(), + host, + port: d.querySelector('#ah-port').value.trim(), + identityFile: d.querySelector('#ah-identity').value.trim(), + options: d.querySelector('#ah-options').value.trim(), + }; + let existing = []; + try { existing = (await window.api.getRemoteTargets()).filter(h => h.source === 'manual'); } catch {} + const res = await window.api.saveRemoteHosts([...existing, newHost]); + if (!res || !res.ok) { setErr('Failed to save host.'); return; } + const saved = (res.hosts || []).find(h => h.source === 'manual' && h.host === host && (h.user || '') === (newHost.user || '')) || null; + close(saved); + }; + }); +} + +async function showAddProjectDialog() { const overlay = document.createElement('div'); overlay.className = 'add-project-overlay'; const dialog = document.createElement('div'); dialog.className = 'add-project-dialog'; + // Load remote SSH targets for the Remote tab. + let remoteTargets = []; + try { remoteTargets = await window.api.getRemoteTargets(); } catch {} + const buildHostOptions = (targets) => + (targets.length ? '' : '') + + targets.map(h => ``).join('') + + ''; + const hostOptions = buildHostOptions(remoteTargets); + dialog.innerHTML = `

Add Project

-
Select a folder to create a new project. To start a session in an existing project, use the + on its project header.
-
- - +
+ + +
+
+
Select a folder to create a new project. To start a session in an existing project, use the + on its project header.
+
+ + +
+
+
@@ -453,29 +944,47 @@ function showAddProjectDialog() { const pathInput = dialog.querySelector('#add-project-path'); const errorEl = dialog.querySelector('#add-project-error'); + const localPane = dialog.querySelector('#add-project-local'); + const remotePane = dialog.querySelector('#add-project-remote'); + let tab = 'local'; pathInput.focus(); + dialog.querySelectorAll('.add-project-tab').forEach(btn => { + btn.onclick = () => { + tab = btn.dataset.tab; + dialog.querySelectorAll('.add-project-tab').forEach(b => b.classList.toggle('selected', b === btn)); + localPane.style.display = tab === 'local' ? '' : 'none'; + remotePane.style.display = tab === 'remote' ? '' : 'none'; + errorEl.style.display = 'none'; + }; + }); + function close() { overlay.remove(); document.removeEventListener('keydown', onKey); } + function showError(msg) { errorEl.textContent = msg; errorEl.style.display = 'block'; } + async function addProject() { - const projectPath = pathInput.value.trim(); - if (!projectPath) { - errorEl.textContent = 'Please enter a folder path.'; - errorEl.style.display = 'block'; - return; - } errorEl.style.display = 'none'; - const result = await window.api.addProject(projectPath); - if (result.error) { - errorEl.textContent = result.error; - errorEl.style.display = 'block'; + if (tab === 'remote') { + const hostSel = dialog.querySelector('#add-remote-host'); + if (!hostSel) { showError('No SSH hosts configured. Add them in Settings → Remote Hosts.'); return; } + const hostId = hostSel.value; + if (!hostId || hostId === '__add__') { showError('Select or add a host.'); return; } + const remotePath = dialog.querySelector('#add-remote-path').value.trim() || '~'; + const result = await window.api.addRemoteProject({ hostId, remotePath }); + if (result.error) { showError(result.error); return; } + close(); + await loadProjects(); return; } + const projectPath = pathInput.value.trim(); + if (!projectPath) { showError('Please enter a folder path.'); return; } + const result = await window.api.addProject(projectPath); + if (result.error) { showError(result.error); return; } close(); - await loadProjects(); } @@ -484,6 +993,62 @@ function showAddProjectDialog() { if (folder) pathInput.value = folder; }; + // "+ Add new host…" in the host dropdown opens an inline add-host form. + const hostSelEl = dialog.querySelector('#add-remote-host'); + if (hostSelEl) { + let prevValue = hostSelEl.value; + hostSelEl.addEventListener('change', async () => { + if (hostSelEl.value !== '__add__') { prevValue = hostSelEl.value; return; } + const saved = await showAddHostDialog(); + if (saved) { + try { remoteTargets = await window.api.getRemoteTargets(); } catch {} + hostSelEl.innerHTML = buildHostOptions(remoteTargets); + hostSelEl.value = saved.id; + prevValue = hostSelEl.value; + } else { + hostSelEl.value = prevValue; + } + }); + } + + const isRealHost = () => { const v = hostSelEl && hostSelEl.value; return v && v !== '__add__'; }; + + // Remote directory Browse + const remoteBrowseBtn = dialog.querySelector('#add-remote-browse'); + if (remoteBrowseBtn) { + remoteBrowseBtn.onclick = async () => { + if (!isRealHost()) { showError('Select or add a host first.'); return; } + const hostSel = dialog.querySelector('#add-remote-host'); + const remotePathInput = dialog.querySelector('#add-remote-path'); + const t = remoteTargets.find(h => h.id === hostSel.value); + const host = { id: hostSel.value, label: (t && t.label) || hostSel.value }; + const picked = await showRemoteDirBrowser(host, remotePathInput.value.trim() || '~'); + if (picked) remotePathInput.value = picked; + }; + } + + // Interactive Connect: open a real shell to the selected host to authenticate + // (password/pubkey/legacy). Closes this dialog and warms the connection so a + // subsequent Add → Browse works. The host label carries the ssh-config suffix, + // so strip it back to the pickable label for the session. + const remoteConnectBtn = dialog.querySelector('#add-remote-connect'); + if (remoteConnectBtn) { + remoteConnectBtn.onclick = async () => { + if (!isRealHost()) { showError('Select or add a host first.'); return; } + const hostSel = dialog.querySelector('#add-remote-host'); + const t = remoteTargets.find(h => h.id === hostSel.value); + const host = { id: hostSel.value, label: (t && t.label) || hostSel.value }; + const orig = remoteConnectBtn.textContent; + remoteConnectBtn.disabled = true; + remoteConnectBtn.textContent = 'Connecting…'; + remoteConnectBtn.classList.remove('connected'); + const ok = await connectRemoteHost(host); + remoteConnectBtn.disabled = false; + if (ok) { remoteConnectBtn.textContent = '✓ Connected'; remoteConnectBtn.classList.add('connected'); } + else { remoteConnectBtn.textContent = orig; } + }; + } + dialog.querySelector('.add-project-cancel-btn').onclick = close; dialog.querySelector('.add-project-add-btn').onclick = addProject; overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); diff --git a/public/settings-panel.js b/public/settings-panel.js index 5c95450a..686e7d18 100644 --- a/public/settings-panel.js +++ b/public/settings-panel.js @@ -79,6 +79,12 @@ let shellProfiles = []; try { shellProfiles = await window.api.getShellProfiles(); } catch {}; + // Discover remote SSH targets (global settings only) + let remoteTargets = []; + if (!isProject) { + try { remoteTargets = await window.api.getRemoteTargets(); } catch {} + } + settingsViewerBody.innerHTML = `
@@ -236,6 +242,18 @@
` : ''} + ${!isProject ? `
+
Remote Hosts (SSH)
+
Hosts from ~/.ssh/config are imported automatically. Add extra hosts below. Authentication uses your SSH agent/keys — passwords are never stored.
+
+
+
+ + +
+
+
` : ''} + ${!isProject ? `
Updates
@@ -377,6 +395,137 @@ }); } + // Remote Hosts section (global settings only) + const remoteManualList = settingsViewerBody.querySelector('#sv-remote-manual-list'); + if (remoteManualList) { + const configHosts = remoteTargets.filter(h => h.source === 'config'); + const toEditable = (h) => ({ + id: h.id, label: h.label || '', user: h.user || '', host: h.host || '', port: h.port || '', + identityFile: h.identityFile || '', + options: Array.isArray(h.options) ? h.options.join(', ') : (h.options || ''), + }); + let manualHosts = remoteTargets.filter(h => h.source === 'manual').map(toEditable); + const statusEl = settingsViewerBody.querySelector('#sv-remote-status'); + const configListEl = settingsViewerBody.querySelector('#sv-remote-config-list'); + + const setStatus = (msg, kind) => { + statusEl.textContent = msg || ''; + statusEl.className = 'remote-hosts-status' + (kind ? ' ' + kind : ''); + }; + + const renderConfig = () => { + if (!configHosts.length) { configListEl.innerHTML = ''; return; } + configListEl.innerHTML = '
From ~/.ssh/config
' + + configHosts.map(h => + `
+ ${escapeHtml(h.label)} + ${escapeHtml((h.user ? h.user + '@' : '') + (h.hostName || h.host || ''))}${h.port ? ':' + h.port : ''} + + +
`).join(''); + }; + + const renderManual = () => { + remoteManualList.innerHTML = manualHosts.map((h, i) => + `
+
+ + + + +
+
+ +
+
+ +
+
+ ${h.id ? ` + ` : 'Save to enable Connect/Test'} + + +
+
`).join(''); + remoteManualList.querySelectorAll('.remote-host-card').forEach(card => { + const i = Number(card.dataset.i); + card.querySelectorAll('input').forEach(inp => { + inp.addEventListener('input', () => { manualHosts[i][inp.dataset.f] = inp.value; manualHosts[i].id = undefined; }); + }); + const rm = card.querySelector('.remote-remove-btn'); + if (rm) rm.addEventListener('click', () => { manualHosts.splice(i, 1); renderManual(); }); + }); + }; + + settingsViewerBody.querySelector('#sv-remote-add-btn').addEventListener('click', () => { + manualHosts.push({ label: '', user: '', host: '', port: '', identityFile: '', options: '' }); + renderManual(); + }); + + settingsViewerBody.querySelector('#sv-remote-save-btn').addEventListener('click', async () => { + const toSave = manualHosts.filter(h => h.host && String(h.host).trim()); + try { + const res = await window.api.saveRemoteHosts(toSave); + if (res && res.ok) { + setStatus('Saved ' + toSave.length + ' host(s).', 'ok'); + manualHosts = (res.hosts || []).filter(h => h.source === 'manual').map(toEditable); + renderManual(); + } else { + setStatus('Failed to save hosts.', 'err'); + } + } catch (err) { setStatus('Save error: ' + err.message, 'err'); } + }); + + // Delegated handler for Connect / Test / write-config, bound to the freshly + // rendered form so listeners don't accumulate across re-opens. + settingsViewerBody.querySelector('.settings-form').addEventListener('click', async (e) => { + // Interactive Connect: open a real shell to the host (any auth) — warms the + // shared connection so Browse/sessions work right after. + const conn = e.target.closest('.remote-connect-btn'); + if (conn) { + const host = { id: conn.dataset.id, label: conn.dataset.label || conn.dataset.id }; + const orig = conn.textContent; + conn.disabled = true; conn.textContent = 'Connecting…'; + setStatus('Connecting to ' + host.label + '…'); + let ok = false; + try { ok = (typeof connectRemoteHost === 'function') ? await connectRemoteHost(host) : false; } catch {} + conn.disabled = false; + if (ok) { conn.textContent = '✓ Connected'; conn.classList.add('connected'); setStatus('✓ ' + host.label + ' connected (ready to Browse).', 'ok'); } + else { conn.textContent = orig; setStatus('', ''); } + return; + } + // Write a manual host into ~/.ssh/config. + const wcfg = e.target.closest('.remote-writecfg-btn'); + if (wcfg) { + const h = manualHosts[Number(wcfg.dataset.i)]; + if (!h || !h.host || !String(h.host).trim()) { setStatus('Enter a host first.', 'err'); return; } + wcfg.disabled = true; + try { + const r = await window.api.writeSshConfig(h); + if (r.ok) setStatus('✓ Wrote "' + r.label + '" to ~/.ssh/config. Reopen settings to see it under imported hosts.', 'ok'); + else setStatus('✗ ' + (r.error || 'failed to write config'), 'err'); + } catch (err) { setStatus('✗ ' + err.message, 'err'); } + wcfg.disabled = false; + return; + } + // Quick non-interactive reachability probe. + const btn = e.target.closest('.remote-test-btn'); + if (!btn) return; + const id = btn.dataset.id; + const old = btn.textContent; + btn.disabled = true; btn.textContent = 'Testing…'; + setStatus('Testing ' + id + '…'); + try { + const r = await window.api.testRemoteHost(id); + setStatus((r.reachable ? '✓ ' : '✗ ') + id + ': ' + (r.message || (r.ok ? 'reachable' : 'failed')), r.reachable ? 'ok' : 'err'); + } catch (err) { setStatus('✗ test error: ' + err.message, 'err'); } + btn.disabled = false; btn.textContent = old; + }); + + renderConfig(); + renderManual(); + } + // Remove project button const removeBtn = settingsViewerBody.querySelector('#sv-remove-btn'); if (removeBtn) { diff --git a/public/sidebar.js b/public/sidebar.js index 45985dd0..314181ed 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -284,8 +284,14 @@ function renderProjects(projects, resort) { const header = document.createElement('div'); header.className = 'project-header'; header.id = 'ph-' + fId; - const shortName = project.projectPath.split('/').filter(Boolean).slice(-2).join('/'); - header.innerHTML = ` ${shortName}`; + let shortName, remotePrefix = ''; + if (project.remote) { + shortName = `${project.hostLabel} : ${project.remotePath || '~'}`; + remotePrefix = 'SSH '; + } else { + shortName = project.projectPath.split('/').filter(Boolean).slice(-2).join('/'); + } + header.innerHTML = ` ${remotePrefix}${shortName}`; const scheduleBtn = document.createElement('button'); scheduleBtn.className = 'project-schedule-btn'; @@ -644,7 +650,10 @@ function buildSessionItem(session) { const item = document.createElement('div'); item.className = 'session-item'; item.id = 'si-' + session.sessionId; - if (session.type === 'terminal') item.classList.add('is-terminal'); + // A remote Claude session runs `claude` (not a plain shell), so it should read + // like a Claude session — no terminal badge/styling, just the SSH badge. + const isRemoteClaude = session.remote && session.remoteMode !== 'shell'; + if (session.type === 'terminal' && !isRemoteClaude) item.classList.add('is-terminal'); if (session.archived) item.classList.add('archived-item'); if (activePtyIds.has(session.sessionId)) item.classList.add('has-running-pty'); if (attentionSessions.has(session.sessionId)) item.classList.add('needs-attention'); @@ -686,12 +695,19 @@ function buildSessionItem(session) { metaEl.className = 'session-meta'; metaEl.textContent = timeStr + (session.messageCount ? ' \u00b7 ' + session.messageCount + ' msgs' : ''); - if (session.type === 'terminal') { + if (session.type === 'terminal' && !isRemoteClaude) { const badge = document.createElement('span'); badge.className = 'terminal-badge'; badge.innerHTML = ''; summaryEl.prepend(badge); } + if (session.remote) { + const rbadge = document.createElement('span'); + rbadge.className = 'remote-badge'; + rbadge.textContent = 'SSH'; + if (session.remoteLabel) rbadge.title = 'Remote: ' + session.remoteLabel; + summaryEl.prepend(rbadge); + } info.appendChild(summaryEl); info.appendChild(idEl); info.appendChild(metaEl); diff --git a/public/style.css b/public/style.css index f6070c0a..7ae5ad32 100644 --- a/public/style.css +++ b/public/style.css @@ -2695,8 +2695,20 @@ body { display: flex; flex-direction: column; } align-items: center; } +.add-project-dialog .folder-input-row select { + flex: 1; + min-width: 0; /* allow the select to shrink so it never overlaps the adjacent button */ +} + +/* Remote tab rows: fixed-width label + flexible control, so a widening button + (Connect -> Connecting… -> ✓ Connected) can never reflow into the label. */ +#add-project-remote .settings-field-info { flex: 0 0 130px; } +#add-project-remote .settings-field-control { flex: 1 1 auto; min-width: 0; } +#add-remote-connect { min-width: 116px; text-align: center; justify-content: center; flex: 0 0 auto; } + .add-project-dialog .folder-input-row input { flex: 1; + min-width: 0; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 6px; @@ -3464,12 +3476,204 @@ body { display: flex; flex-direction: column; } top: -1px; } +.remote-badge { + display: inline-flex; + align-items: center; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.5px; + line-height: 1; + padding: 2px 4px; + margin-right: 5px; + border-radius: 3px; + color: #6cb6ff; + background: rgba(108, 182, 255, 0.14); + border: 1px solid rgba(108, 182, 255, 0.35); + vertical-align: middle; + position: relative; + top: -1px; +} + /* Terminal sessions: green status dot */ .session-item.is-terminal .session-status-dot.running { background: #3ecf5a; box-shadow: 0 0 6px rgba(62,207,90,0.5); } +/* Remote hosts settings section */ +.remote-config-title { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.4px; + opacity: 0.6; + margin: 4px 0 6px; + padding-left: 2px; +} +/* Imported host list: styled as a settings-style table (matches the rows in the + Application section — rounded group, dividers, label left / controls right). */ +.remote-config-list { + margin-bottom: 12px; + border-radius: 10px; + overflow: hidden; +} +.remote-config-list .remote-config-title { + margin: 0; + padding: 8px 18px; + background: rgba(255,255,255,0.02); +} +.remote-host-row { + display: flex; + align-items: center; + gap: 12px; + padding: 4px 0; +} +.remote-config-list .remote-host-row { + padding: 12px 18px; + min-height: 48px; + background: rgba(255,255,255,0.02); + border-top: 1px solid rgba(255,255,255,0.05); +} +.remote-host-name { font-size: 13.5px; font-weight: 600; letter-spacing: -0.01em; min-width: 130px; color: #e0e0f0; } +.remote-host-detail { opacity: 0.65; flex: 1; font-size: 12.5px; } +.remote-hosts-actions { + display: flex; + gap: 8px; + margin-top: 8px; +} +.remote-test-btn, .remote-remove-btn, .remote-connect-btn, .remote-writecfg-btn { + background: rgba(127,127,127,0.12); + border: 1px solid rgba(127,127,127,0.3); + border-radius: 4px; + color: inherit; + cursor: pointer; + font-size: 11px; + padding: 3px 8px; +} +.remote-remove-btn { padding: 3px 7px; } +.remote-connect-btn { + color: #6cb6ff; + border-color: rgba(108,182,255,0.45); + background: rgba(108,182,255,0.12); +} +.remote-test-btn:hover, .remote-remove-btn:hover, .remote-connect-btn:hover, .remote-writecfg-btn:hover { background: rgba(127,127,127,0.22); } +.remote-connect-btn:hover { background: rgba(108,182,255,0.22); } + +/* Connected state for Connect buttons */ +.remote-connect-btn.connected, +.add-project-browse-btn.connected { + color: #3ecf5a; + border-color: rgba(62,207,90,0.5); + background: rgba(62,207,90,0.12); +} + +/* Add-host example-option chips */ +.ah-opt-chips { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 6px; +} +.ah-chip { + font-size: 10px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + padding: 2px 6px; + border-radius: 10px; + border: 1px solid rgba(108,182,255,0.35); + background: rgba(108,182,255,0.10); + color: #6cb6ff; + cursor: pointer; +} +.ah-chip:hover { background: rgba(108,182,255,0.20); } + +/* Host-key fingerprint block in the Connect verify popup */ +.auth-fingerprint { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-all; + background: rgba(127,127,127,0.10); + border: 1px solid rgba(127,127,127,0.25); + border-radius: 6px; + padding: 10px; + margin: 10px 0; + max-height: 180px; + overflow-y: auto; +} + +/* Interactive Connect popup (embedded auth terminal) */ +.remote-connect-dialog { width: 640px; max-width: 92vw; } +.remote-connect-hint { font-size: 12px; opacity: 0.75; margin-bottom: 10px; } +.remote-connect-term { + height: 260px; + background: #000; + border-radius: 6px; + padding: 6px; + overflow: hidden; +} +.remote-connect-msg { margin-top: 8px; font-size: 12px; min-height: 14px; } +.remote-connect-msg.ok { color: #3ecf5a; } +.remote-connect-msg.err { color: #ff6b6b; } + +/* Manual host card (multi-field editor) */ +.remote-host-card { + border: 1px solid rgba(127,127,127,0.22); + border-radius: 6px; + padding: 8px; + margin-bottom: 8px; +} +.remote-host-card .remote-host-row { flex-wrap: wrap; } +.remote-host-actions { gap: 6px; } +.remote-hosts-status { + margin-top: 8px; + font-size: 12px; + min-height: 16px; + word-break: break-word; +} +.remote-hosts-status.ok { color: #3ecf5a; } +.remote-hosts-status.err { color: #ff6b6b; } + +/* Add-project Local | Remote tabs */ +.add-project-tabs { display: flex; gap: 6px; margin-bottom: 12px; } +.add-project-tab { + flex: 1; + padding: 6px 10px; + border: 1px solid rgba(127,127,127,0.3); + background: transparent; + color: inherit; + border-radius: 6px; + cursor: pointer; + font-size: 13px; +} +.add-project-tab.selected { + background: rgba(108,182,255,0.16); + border-color: rgba(108,182,255,0.5); +} + +/* Remote directory browser — must stack above the Add Project overlay (z 9999) + since Browse can be opened from within it. */ +.remote-browser-overlay { z-index: 10001; } +.remote-browser-list { + margin-top: 10px; + max-height: 300px; + overflow-y: auto; + border: 1px solid rgba(127,127,127,0.25); + border-radius: 6px; +} +.remote-browser-item { + padding: 6px 10px; + cursor: pointer; + font-size: 13px; + border-bottom: 1px solid rgba(127,127,127,0.12); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.remote-browser-item:hover { background: rgba(108,182,255,0.14); } +.remote-browser-up { opacity: 0.8; } +.remote-browser-status { padding: 10px; opacity: 0.6; font-size: 12px; } +.remote-browser-msg { margin-top: 8px; font-size: 12px; color: #e0a54a; min-height: 14px; } + /* ========== FILE PANEL (MCP Bridge) ========== */ #terminal-split { diff --git a/remote-hosts.js b/remote-hosts.js new file mode 100644 index 00000000..4077dff7 --- /dev/null +++ b/remote-hosts.js @@ -0,0 +1,276 @@ +// Remote SSH host model for Switchboard (Phase 1). +// +// Turns ~/.ssh/config entries and user-defined "manual" hosts into SSH shell +// profiles that the terminal spawner (main.js) can hand to node-pty, and +// assembles the command that runs on the remote host. Pure functions here are +// unit-tested; the fs-backed helpers are thin wrappers over them. +const os = require('os'); +const path = require('path'); +const fs = require('fs'); + +// Single-quote a string for a POSIX shell, escaping embedded single quotes. +function sshSingleQuote(s) { + return "'" + String(s).replace(/'/g, "'\\''") + "'"; +} + +// Parse an ~/.ssh/config document into host entries. +// Only top-level `Host` blocks are considered; wildcard/negated aliases and +// `Match` blocks are ignored. `Include` is not followed (Phase 1 limitation). +function parseSshConfig(text) { + const hosts = []; + const seenAliases = new Set(); + let current = null; + const flush = () => { + if (!current) return; + for (const alias of current.aliases) { + if (alias.includes('*') || alias.includes('?') || alias.startsWith('!')) continue; + if (seenAliases.has(alias)) continue; // first occurrence wins (ssh's own semantics) + seenAliases.add(alias); + hosts.push({ + id: 'config:' + alias, + label: alias, + alias, + hostName: current.hostName || alias, + user: current.user, + port: current.port, + source: 'config', + }); + } + current = null; + }; + + for (const raw of String(text || '').split(/\r?\n/)) { + const line = raw.replace(/#.*$/, '').trim(); + if (!line) continue; + const m = line.match(/^(\S+)[\s=]+(.+)$/); + if (!m) continue; + const key = m[1].toLowerCase(); + const value = m[2].trim(); + if (key === 'host') { + flush(); + current = { aliases: value.split(/\s+/).filter(Boolean), hostName: undefined, user: undefined, port: undefined }; + } else if (key === 'match') { + flush(); // ignore Match blocks + } else if (current) { + if (key === 'hostname') current.hostName = value; + else if (key === 'user') current.user = value; + else if (key === 'port') current.port = parseInt(value, 10); + } + } + flush(); + return hosts; +} + +// Quote a remote directory for use in `cd `, expanding a leading ~ to +// $HOME so the remote shell resolves it (single quotes would suppress it). +function quoteRemoteDir(dir) { + if (dir === '~') return '$HOME'; + if (dir.startsWith('~/')) return '$HOME/' + sshSingleQuote(dir.slice(2)); + return sshSingleQuote(dir); +} + +// Build the command executed on the remote host. +// mode 'claude': cd && exec (cd failure aborts — don't run in the wrong place) +// mode 'shell' : cd 2>/dev/null; exec login-shell (cd is best-effort) +// A home/empty dir needs no cd (a login shell already starts in $HOME). +function buildRemoteCommand(mode, remoteDir, innerCmd) { + const hasDir = remoteDir && remoteDir !== '~' && String(remoteDir).trim() !== ''; + const cd = hasDir ? 'cd ' + quoteRemoteDir(remoteDir) : ''; + if (mode === 'shell') { + const shellExec = 'exec "${SHELL:-bash}" -l'; + return cd ? cd + ' 2>/dev/null; ' + shellExec : shellExec; + } + const inner = 'exec ' + innerCmd; + return cd ? cd + ' && ' + inner : inner; +} + +// The ssh options+target for a host, WITHOUT the -t PTY flag or -o test options. +// Options must precede the target: ssh stops option parsing at the hostname. +// Config hosts resolve everything (key, options, algorithms) from ~/.ssh/config, +// so we only pass the alias. Manual hosts carry their own identity file and extra +// -o options (e.g. legacy HostKeyAlgorithms=+ssh-rsa) inline. +function hostTargetArgs(host) { + if (host.source === 'config') return [host.alias]; + const args = []; + for (const opt of (host.options || [])) { + if (opt && String(opt).trim()) args.push('-o', String(opt).trim()); + } + if (host.identityFile && String(host.identityFile).trim()) args.push('-i', String(host.identityFile).trim()); + const port = host.port ? Number(host.port) : undefined; + if (port && port !== 22) args.push('-p', String(port)); + args.push(host.user ? host.user + '@' + host.host : host.host); + return args; +} + +// Full ssh args for an interactive session (forces a PTY with -t). +function sshArgsForHost(host) { + return ['-t', ...hostTargetArgs(host)]; +} + +// A shell profile (as produced by shell-profiles.js) that spawns ssh. +function buildSshProfile(host) { + const label = host.label || host.alias || host.host; + return { + id: 'ssh:' + host.id, + name: 'SSH — ' + label, + path: 'ssh', + args: sshArgsForHost(host), + remote: true, + remoteHost: host, + }; +} + +// ssh args for a non-interactive connectivity probe. BatchMode=yes ensures we +// never block on a password prompt (key/agent auth only), and a short timeout +// surfaces unreachable hosts quickly. +function testConnectionArgs(host) { + return ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', ...hostTargetArgs(host), 'true']; +} + +// Stable synthetic project path for a remote project / session, so a persisted +// remote project and the live sessions launched into it group together in the +// sidebar. Must be produced identically wherever a remote path is formed. +function remoteProjectPath(hostLabel, remotePath) { + return 'ssh://' + hostLabel + '/' + (remotePath && String(remotePath).trim() ? remotePath : '~'); +} + +// Classify a non-interactive ssh probe (see testConnectionArgs). BatchMode never +// prompts, so a password-auth host "fails" — but its failure text tells us the +// host is actually reachable and simply needs interactive auth on connect. +function classifyConnResult(exitCode, output) { + const out = String(output || ''); + if (exitCode === 0) return { status: 'ok', reachable: true, message: 'Reachable (passwordless key/agent auth works).' }; + if (/permission denied|password:|publickey|authentication failed|too many authentication/i.test(out)) { + return { status: 'auth', reachable: true, message: 'Reachable — will prompt for password/key auth on connect.' }; + } + if (/host key verification failed|authenticity of host|fingerprint|known_hosts/i.test(out)) { + return { status: 'hostkey', reachable: true, message: 'Reachable — first connection needs host-key confirmation (accept it when you launch a session).' }; + } + if (/connection refused|could not resolve|name or service not known|no route to host|network is unreachable|operation timed out|connection timed out|timed out|connection closed/i.test(out)) { + return { status: 'unreachable', reachable: false, message: 'Unreachable: ' + (out.split('\n').filter(Boolean).slice(-1)[0] || 'no response') }; + } + return { status: 'unknown', reachable: false, message: out.split('\n').filter(Boolean).slice(-1)[0] || 'Unknown result' }; +} + +// SSH connection-multiplexing options. A shared control socket per host lets the +// directory browser (and repeated ops) reuse one authenticated connection, so you +// only authenticate once per host and subsequent listings are instant. +function controlArgs(socketPath) { + return ['-o', 'ControlMaster=auto', '-o', 'ControlPath=' + socketPath, '-o', 'ControlPersist=600']; +} + +// Short, stable, length-bounded control-socket path for a host (unix socket paths +// are capped ~104 bytes, so we hash the id rather than embed it). +function controlSocketPath(baseDir, hostId) { + let h = 5381; + const s = String(hostId); + for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; + return baseDir.replace(/\/$/, '') + '/swb-' + Math.abs(h).toString(36) + '.sock'; +} + +// Parse `ls -1Ap` output into the list of subdirectory names (lines the shell +// marked with a trailing slash), stripped and sorted. +function parseLsDirs(output) { + const dirs = []; + for (const raw of String(output || '').split(/\r?\n/)) { + const line = raw.replace(/\r$/, ''); + if (line.endsWith('/')) { + const name = line.slice(0, -1); + if (name && name !== '.' && name !== '..') dirs.push(name); + } + } + return dirs.sort(); +} + +// ssh args to list directories at `path` on a host over the control socket. +// BatchMode: never blocks on a prompt — if the connection isn't already +// authenticated (no live master, no key auth) it fails fast and the caller +// reports "needs auth" instead of hanging. +function browseArgs(host, socketPath, path) { + const opts = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', ...controlArgs(socketPath)]; + const cmd = 'ls -1Ap -- ' + quoteRemoteDir(path); + return [...opts, ...hostTargetArgs(host), cmd]; +} + +// --- fs-backed helpers (main process) --- + +function defaultSshConfigPath() { + return path.join(os.homedir(), '.ssh', 'config'); +} + +function readSshConfigHosts(configPath) { + try { + const p = configPath || defaultSshConfigPath(); + if (!fs.existsSync(p)) return []; + return parseSshConfig(fs.readFileSync(p, 'utf8')); + } catch { + return []; + } +} + +function normalizeManualHost(h) { + const host = (h.host || '').trim(); + const port = h.port ? Number(h.port) : undefined; + const user = (h.user || '').trim() || undefined; + const identityFile = (h.identityFile || '').trim() || undefined; + // options may arrive as an array or a free-form string (newline/comma separated) + let options = []; + if (Array.isArray(h.options)) { + options = h.options.map(o => String(o).trim()).filter(Boolean); + } else if (typeof h.options === 'string') { + options = h.options.split(/[\n,]+/).map(o => o.trim()).filter(Boolean); + } + const id = h.id || ('manual:' + (user ? user + '@' : '') + host + (port ? ':' + port : '')); + return { id, label: (h.label || '').trim() || host || id, host, user, port, identityFile, options, source: 'manual' }; +} + +// Render a host as an ~/.ssh/config `Host` block. ssh config uses "Key Value" +// (space-separated), so options given as "Key=Value" are converted. +function buildSshConfigEntry(host) { + const lines = ['Host ' + (host.label || host.alias || host.host)]; + if (host.host) lines.push(' HostName ' + host.host); + if (host.user) lines.push(' User ' + host.user); + const port = host.port ? Number(host.port) : undefined; + if (port && port !== 22) lines.push(' Port ' + port); + if (host.identityFile) lines.push(' IdentityFile ' + host.identityFile); + for (const opt of (host.options || [])) { + const s = String(opt).trim(); + if (!s) continue; + const eq = s.indexOf('='); + lines.push(' ' + (eq >= 0 ? s.slice(0, eq) + ' ' + s.slice(eq + 1) : s)); + } + return lines.join('\n'); +} + +// Merge config-derived hosts with user-defined manual hosts (from settings). +function loadRemoteHosts(manualHosts, configPath) { + const config = readSshConfigHosts(configPath); + const manual = (manualHosts || []).filter(h => h && h.host).map(normalizeManualHost); + return [...config, ...manual]; +} + +function findRemoteHost(id, manualHosts, configPath) { + return loadRemoteHosts(manualHosts, configPath).find(h => h.id === id) || null; +} + +module.exports = { + parseSshConfig, + quoteRemoteDir, + buildRemoteCommand, + hostTargetArgs, + sshArgsForHost, + buildSshProfile, + testConnectionArgs, + remoteProjectPath, + classifyConnResult, + controlArgs, + controlSocketPath, + parseLsDirs, + browseArgs, + buildSshConfigEntry, + readSshConfigHosts, + normalizeManualHost, + loadRemoteHosts, + findRemoteHost, + defaultSshConfigPath, +}; diff --git a/session-cache.js b/session-cache.js index f066004e..9d0837de 100644 --- a/session-cache.js +++ b/session-cache.js @@ -239,6 +239,24 @@ function buildProjectsFromCache(showArchived) { } } catch {} + // Inject persisted remote projects (Model A: a project can be local or remote). + // These have no local .jsonl; live sessions launched into them attach below. + for (const rp of (global.remoteProjects || [])) { + if (!rp || !rp.projectPath) continue; + if (hiddenProjects.has(rp.projectPath)) continue; + if (!projectMap.has(rp.projectPath)) { + projectMap.set(rp.projectPath, { + folder: encodeProjectPath(rp.projectPath), + projectPath: rp.projectPath, + sessions: [], + remote: true, + hostId: rp.hostId, + hostLabel: rp.hostLabel, + remotePath: rp.remotePath, + }); + } + } + // Inject active plain terminal sessions so they participate in sorting for (const [sessionId, session] of activeSessions) { if (session.exited || !session.isPlainTerminal) continue; @@ -253,12 +271,17 @@ function buildProjectsFromCache(showArchived) { } const proj = projectMap.get(session.projectPath); if (!proj.sessions.some(s => s.sessionId === sessionId)) { + const remoteLabel = session.remoteHost ? session.remoteHost.label : null; + const summary = session.remote + ? ((session.remoteMode === 'shell' ? 'Shell @ ' : 'Claude @ ') + remoteLabel) + : 'Terminal'; proj.sessions.push({ - sessionId, summary: 'Terminal', firstPrompt: '', projectPath: session.projectPath, + sessionId, summary, firstPrompt: '', projectPath: session.projectPath, name: null, starred: 0, archived: 0, messageCount: 0, modified: new Date(session._openedAt).toISOString(), created: new Date(session._openedAt).toISOString(), type: 'terminal', + remote: !!session.remote, remoteLabel, remoteMode: session.remoteMode || null, }); } } diff --git a/shell-profiles.js b/shell-profiles.js index b39a7102..8fad6eb7 100644 --- a/shell-profiles.js +++ b/shell-profiles.js @@ -112,7 +112,7 @@ function resolveShell(profileId) { if (profileId && profileId !== 'auto') { const profiles = getShellProfiles(); const profile = profiles.find(p => p.id === profileId); - if (profile && (profile.path === 'wsl.exe' || fs.existsSync(profile.path))) { + if (profile && (profile.path === 'wsl.exe' || isSshProfile(profile.path) || fs.existsSync(profile.path))) { return profile; } } @@ -160,6 +160,18 @@ function isWslShell(shellPath) { return base === 'wsl.exe' || base === 'wsl'; } +// An SSH profile wraps the `ssh` command (path: 'ssh'). Detected by basename +// so it works whether the caller passes 'ssh' or an absolute '/usr/bin/ssh'. +function isSshProfile(shellPath) { + const base = path.basename(String(shellPath || '')).toLowerCase().replace(/\.exe$/, ''); + return base === 'ssh'; +} + +// Single-quote a string for a POSIX shell (escaping embedded single quotes). +function sshSingleQuote(s) { + return "'" + String(s).replace(/'/g, "'\\''") + "'"; +} + // Returns spawn args appropriate for the resolved shell function shellArgs(shellPath, cmd, extraArgs) { const base = path.basename(shellPath).toLowerCase(); @@ -174,6 +186,16 @@ function shellArgs(shellPath, cmd, extraArgs) { return [...(extraArgs || []), '--', 'bash', '-l', '-i']; } + // SSH: run the command on the remote inside a login+interactive bash so the + // remote PATH is set up (mirrors WSL). ssh concatenates the argv after the + // hostname with spaces and the remote shell re-parses it, so the payload must + // be single-quoted to survive as one argument. extraArgs carries the ssh + // options and target (e.g. ['-t', 'user@host']). + if (isSshProfile(shellPath)) { + const remote = cmd || 'exec "${SHELL:-bash}" -l'; + return [...(extraArgs || []), 'bash', '-l', '-i', '-c', sshSingleQuote(remote)]; + } + if (cmd) { if (isBashLike) return ['-l', '-i', '-c', cmd]; if (isFish) return ['-l', '-c', cmd]; @@ -188,4 +210,4 @@ function shellArgs(shellPath, cmd, extraArgs) { return []; } -module.exports = { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs }; +module.exports = { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs, isSshProfile }; diff --git a/test/remote-ssh.test.js b/test/remote-ssh.test.js new file mode 100644 index 00000000..41881ab6 --- /dev/null +++ b/test/remote-ssh.test.js @@ -0,0 +1,260 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); + +const { isSshProfile, shellArgs } = require('../shell-profiles'); +const { + parseSshConfig, + quoteRemoteDir, + buildRemoteCommand, + hostTargetArgs, + sshArgsForHost, + buildSshProfile, + testConnectionArgs, + remoteProjectPath, + classifyConnResult, + controlArgs, + parseLsDirs, + browseArgs, + normalizeManualHost, + buildSshConfigEntry, +} = require('../remote-hosts'); + +// --- isSshProfile --- + +test('isSshProfile recognizes ssh by basename', () => { + assert.equal(isSshProfile('ssh'), true); + assert.equal(isSshProfile('/usr/bin/ssh'), true); + assert.equal(isSshProfile('ssh.exe'), true); +}); + +test('isSshProfile rejects non-ssh shells', () => { + assert.equal(isSshProfile('/bin/bash'), false); + assert.equal(isSshProfile('wsl.exe'), false); + assert.equal(isSshProfile(''), false); + assert.equal(isSshProfile(undefined), false); +}); + +// --- shellArgs SSH branch --- + +test('shellArgs wraps a remote command as a single-quoted bash -l -i -c payload', () => { + const args = shellArgs('ssh', 'exec claude', ['-t', 'myhost']); + assert.deepEqual(args, ['-t', 'myhost', 'bash', '-l', '-i', '-c', "'exec claude'"]); +}); + +test('shellArgs without a command opens a remote login shell', () => { + const args = shellArgs('ssh', undefined, ['-t', 'myhost']); + assert.deepEqual(args, ['-t', 'myhost', 'bash', '-l', '-i', '-c', `'exec "\${SHELL:-bash}" -l'`]); +}); + +test('shellArgs single-quotes a remote command containing spaces and quotes', () => { + const args = shellArgs('ssh', "cd $HOME/'a b' && exec claude", ['-t', 'h']); + // inner single quotes must be escaped as '\'' so the whole payload survives as one arg + assert.equal(args[args.length - 1], `'cd $HOME/'\\''a b'\\'' && exec claude'`); +}); + +// --- parseSshConfig --- + +test('parseSshConfig parses hosts and skips wildcards', () => { + const cfg = [ + 'Host prod', + ' HostName prod.example.com', + ' User deploy', + ' Port 2222', + '', + 'Host *', + ' ForwardAgent yes', + '', + 'Host bastion', + ' HostName 10.0.0.1', + ].join('\n'); + const hosts = parseSshConfig(cfg); + assert.equal(hosts.length, 2); + const prod = hosts.find(h => h.alias === 'prod'); + assert.equal(prod.hostName, 'prod.example.com'); + assert.equal(prod.user, 'deploy'); + assert.equal(prod.port, 2222); + assert.equal(prod.source, 'config'); + const bastion = hosts.find(h => h.alias === 'bastion'); + assert.equal(bastion.hostName, '10.0.0.1'); + assert.equal(bastion.user, undefined); +}); + +test('parseSshConfig de-duplicates repeated aliases (first wins)', () => { + const cfg = [ + 'Host dup', + ' HostName a.example.com', + 'Host other', + ' HostName o.example.com', + 'Host dup', + ' HostName b.example.com', + ].join('\n'); + const hosts = parseSshConfig(cfg); + assert.equal(hosts.filter(h => h.alias === 'dup').length, 1); + assert.equal(hosts.find(h => h.alias === 'dup').hostName, 'a.example.com'); + assert.equal(hosts.length, 2); +}); + +test('parseSshConfig handles key=value and multiple aliases', () => { + const cfg = [ + 'Host web1 web2', + ' HostName=example.com', + ' User=root', + ].join('\n'); + const hosts = parseSshConfig(cfg); + assert.deepEqual(hosts.map(h => h.alias).sort(), ['web1', 'web2']); + assert.equal(hosts[0].hostName, 'example.com'); + assert.equal(hosts[0].user, 'root'); +}); + +// --- quoteRemoteDir --- + +test('quoteRemoteDir expands leading tilde to $HOME and quotes the rest', () => { + assert.equal(quoteRemoteDir('~'), '$HOME'); + assert.equal(quoteRemoteDir('~/foo'), "$HOME/'foo'"); + assert.equal(quoteRemoteDir('/a b'), "'/a b'"); + assert.equal(quoteRemoteDir("/x'y"), "'/x'\\''y'"); +}); + +// --- buildRemoteCommand --- + +test('buildRemoteCommand (claude) skips cd for home and cds otherwise', () => { + assert.equal(buildRemoteCommand('claude', '~', 'claude'), 'exec claude'); + assert.equal( + buildRemoteCommand('claude', '/proj', 'claude --dangerously-skip-permissions'), + "cd '/proj' && exec claude --dangerously-skip-permissions" + ); + assert.equal(buildRemoteCommand('claude', '~/work', 'claude'), "cd $HOME/'work' && exec claude"); +}); + +test('buildRemoteCommand (shell) execs the login shell, tolerating a failed cd', () => { + assert.equal(buildRemoteCommand('shell', '~', null), 'exec "${SHELL:-bash}" -l'); + assert.equal( + buildRemoteCommand('shell', '/proj', null), + `cd '/proj' 2>/dev/null; exec "\${SHELL:-bash}" -l` + ); +}); + +// --- host -> ssh args --- + +test('hostTargetArgs uses the alias for config hosts', () => { + assert.deepEqual(hostTargetArgs({ source: 'config', alias: 'prod' }), ['prod']); +}); + +test('hostTargetArgs builds user@host and passes a non-default port before the target', () => { + assert.deepEqual( + hostTargetArgs({ source: 'manual', host: '1.2.3.4', user: 'bob', port: 22 }), + ['bob@1.2.3.4'] + ); + assert.deepEqual( + hostTargetArgs({ source: 'manual', host: '1.2.3.4', user: 'bob', port: 2222 }), + ['-p', '2222', 'bob@1.2.3.4'] + ); + assert.deepEqual(hostTargetArgs({ source: 'manual', host: '1.2.3.4' }), ['1.2.3.4']); +}); + +test('hostTargetArgs injects identity file and extra -o options (manual), options before target', () => { + const host = { + source: 'manual', host: 'h', user: 'u', port: 2222, + identityFile: '/keys/id_rsa', + options: ['HostKeyAlgorithms=+ssh-rsa', 'PreferredAuthentications=password'], + }; + assert.deepEqual(hostTargetArgs(host), [ + '-o', 'HostKeyAlgorithms=+ssh-rsa', + '-o', 'PreferredAuthentications=password', + '-i', '/keys/id_rsa', + '-p', '2222', + 'u@h', + ]); + // target is last + assert.equal(hostTargetArgs(host).slice(-1)[0], 'u@h'); +}); + +test('normalizeManualHost parses options from a string and trims identity file', () => { + const h = normalizeManualHost({ host: 'h', user: 'u', identityFile: ' ~/.ssh/k ', options: 'HostKeyAlgorithms=+ssh-rsa\nPreferredAuthentications=password , Ciphers=aes128-ctr' }); + assert.equal(h.identityFile, '~/.ssh/k'); + assert.deepEqual(h.options, ['HostKeyAlgorithms=+ssh-rsa', 'PreferredAuthentications=password', 'Ciphers=aes128-ctr']); + // already-array options pass through + assert.deepEqual(normalizeManualHost({ host: 'h', options: ['A=1'] }).options, ['A=1']); +}); + +test('buildSshConfigEntry emits a valid Host block (Key Value, not Key=Value)', () => { + const entry = buildSshConfigEntry({ label: 'prod', host: 'prod.example.com', user: 'deploy', port: 2222, identityFile: '~/.ssh/id', options: ['HostKeyAlgorithms=+ssh-rsa'] }); + assert.match(entry, /^Host prod$/m); + assert.match(entry, /^\s+HostName prod\.example\.com$/m); + assert.match(entry, /^\s+User deploy$/m); + assert.match(entry, /^\s+Port 2222$/m); + assert.match(entry, /^\s+IdentityFile ~\/\.ssh\/id$/m); + assert.match(entry, /^\s+HostKeyAlgorithms \+ssh-rsa$/m); + assert.doesNotMatch(entry, /=/); // config uses space-separated, never Key=Value +}); + +test('sshArgsForHost forces a PTY with -t before the target', () => { + assert.deepEqual(sshArgsForHost({ source: 'config', alias: 'prod' }), ['-t', 'prod']); + assert.deepEqual( + sshArgsForHost({ source: 'manual', host: 'h', user: 'u', port: 2200 }), + ['-t', '-p', '2200', 'u@h'] + ); +}); + +test('buildSshProfile produces an ssh shell profile', () => { + const host = { id: 'config:prod', label: 'prod', source: 'config', alias: 'prod' }; + const p = buildSshProfile(host); + assert.equal(p.id, 'ssh:config:prod'); + assert.equal(p.path, 'ssh'); + assert.equal(p.remote, true); + assert.deepEqual(p.args, ['-t', 'prod']); + assert.equal(p.remoteHost, host); + assert.match(p.name, /prod/); +}); + +test('remoteProjectPath builds a stable synthetic path per host+dir', () => { + assert.equal(remoteProjectPath('mi300-7', '~/proj'), 'ssh://mi300-7/~/proj'); + assert.equal(remoteProjectPath('ce-master', '~'), 'ssh://ce-master/~'); + // default dir when omitted + assert.equal(remoteProjectPath('h', ''), 'ssh://h/~'); +}); + +test('classifyConnResult distinguishes reachable/auth/hostkey/unreachable', () => { + assert.equal(classifyConnResult(0, '').status, 'ok'); + assert.equal(classifyConnResult(255, 'user@h: Permission denied (publickey,password).').status, 'auth'); + assert.equal(classifyConnResult(255, 'Host key verification failed.').status, 'hostkey'); + assert.equal(classifyConnResult(255, 'ssh: connect to host h port 22: Connection refused').status, 'unreachable'); + assert.equal(classifyConnResult(255, 'ssh: Could not resolve hostname h: nodename nor servname provided').status, 'unreachable'); + assert.equal(classifyConnResult(255, 'Operation timed out').status, 'unreachable'); + // reachable statuses mean the host answered; unreachable means it did not + assert.equal(classifyConnResult(255, 'Permission denied (publickey,password).').reachable, true); + assert.equal(classifyConnResult(255, 'Connection refused').reachable, false); +}); + +test('controlArgs enables connection multiplexing on a socket path', () => { + assert.deepEqual(controlArgs('/tmp/swb-ab12.sock'), [ + '-o', 'ControlMaster=auto', '-o', 'ControlPath=/tmp/swb-ab12.sock', '-o', 'ControlPersist=600', + ]); +}); + +test('parseLsDirs keeps only directories (ls -1Ap trailing slash), stripped and sorted', () => { + const out = 'file.txt\nprojects/\n.config/\nreadme.md\nsrc/\n'; + assert.deepEqual(parseLsDirs(out), ['.config', 'projects', 'src']); + assert.deepEqual(parseLsDirs(''), []); + assert.deepEqual(parseLsDirs('onlyfile.txt\n'), []); +}); + +test('browseArgs lists a remote dir over the control socket, options before target', () => { + const args = browseArgs({ source: 'config', alias: 'prod' }, '/tmp/swb-x.sock', '~/work'); + // all -o options and the target precede the remote ls command (last element) + assert.equal(args[args.length - 1], "ls -1Ap -- $HOME/'work'"); + assert.ok(args.includes('prod')); + assert.ok(args.includes('BatchMode=yes')); + assert.ok(args.includes('ControlPath=/tmp/swb-x.sock')); + // target must come before the command + assert.ok(args.indexOf('prod') < args.length - 1); +}); + +test('testConnectionArgs uses BatchMode and a short timeout, no PTY', () => { + assert.deepEqual(testConnectionArgs({ source: 'config', alias: 'prod' }), [ + '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', 'prod', 'true', + ]); + assert.deepEqual(testConnectionArgs({ source: 'manual', host: 'h', user: 'u', port: 2200 }), [ + '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', '-p', '2200', 'u@h', 'true', + ]); +});