From 9eb0ecfb57979a88c1c67e00dc6531918d7df030 Mon Sep 17 00:00:00 2001 From: Nikolas Syring Date: Mon, 15 Dec 2025 22:59:34 +0100 Subject: [PATCH] feat: Add SSH remote execution for multi-host Claude sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: SSH execution via spawn() with -T flag for JSON streaming - Backend: PATH setup for non-login shells on remote hosts - Backend: History loading via SSH (tail -n 2000 for large files) - Frontend: Host selector UI with colored buttons in Sidebar - Frontend: Auto-select first project when host changes - Frontend: Pass host parameter to history and session APIs - Docker: Install openssh-client, mount SSH keys Enables running Claude sessions on remote hosts via SSH while viewing them through the web UI. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- backend/Dockerfile | 6 +- backend/server.js | 230 +++++++++++++++++-------- docker-compose.yml | 3 + frontend/src/App.jsx | 5 +- frontend/src/components/Sidebar.jsx | 61 ++++++- frontend/src/hooks/useClaudeSession.js | 14 +- 6 files changed, 236 insertions(+), 83 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index dd6436d..c413764 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,13 +1,17 @@ FROM node:20-slim +# Install SSH client for remote execution +RUN apt-get update && apt-get install -y openssh-client && rm -rf /var/lib/apt/lists/* + # Create app directory WORKDIR /app -# Create node user home structure for claude +# Create node user home structure for claude and ssh RUN mkdir -p /home/node/.local/bin && \ mkdir -p /home/node/.local/share/claude && \ mkdir -p /home/node/.claude && \ mkdir -p /home/node/.config/claude && \ + mkdir -p /home/node/.ssh && \ chown -R node:node /home/node # Create symlink for claude binary diff --git a/backend/server.js b/backend/server.js index c292947..6088fa5 100644 --- a/backend/server.js +++ b/backend/server.js @@ -91,12 +91,19 @@ app.get('/api/projects', (req, res) => { return res.status(404).json({ error: `Host '${hostId}' not found` }); } - // Only local hosts for now + // For SSH hosts, return the basePaths from config (can't scan remote directories) if (host.connection.type !== 'local') { + const projects = host.basePaths.map(basePath => ({ + path: basePath, + name: basename(basePath), + type: 'base', + isBase: true + })); return res.json({ - projects: [], + projects, host: hostId, - message: 'SSH hosts not yet supported for project listing' + hostInfo: { name: host.name, color: host.color }, + message: 'SSH host - showing base paths only' }); } @@ -133,12 +140,118 @@ app.get('/api/health', (req, res) => { }); }); -// Get session history for a project -app.get('/api/history/:project', (req, res) => { +// Parse history content into messages +function parseHistoryContent(content) { + const lines = content.split('\n').filter(l => l.trim()); + const messages = []; + + for (const line of lines) { + try { + const event = JSON.parse(line); + + // Parse user messages + if (event.type === 'user' && event.message?.content) { + const textContent = event.message.content + .filter(c => c.type === 'text') + .map(c => c.text) + .join(''); + if (textContent && !event.tool_use_result) { + messages.push({ + type: 'user', + content: textContent, + timestamp: event.timestamp || Date.now() + }); + } + } + + // Parse assistant messages + if (event.type === 'assistant' && event.message?.content) { + for (const block of event.message.content) { + if (block.type === 'text' && block.text) { + messages.push({ + type: 'assistant', + content: block.text, + timestamp: event.timestamp || Date.now() + }); + } else if (block.type === 'tool_use') { + messages.push({ + type: 'tool_use', + tool: block.name, + input: block.input, + toolUseId: block.id, + timestamp: event.timestamp || Date.now() + }); + } + } + } + + // Parse tool results + if (event.type === 'user' && event.tool_use_result) { + messages.push({ + type: 'tool_result', + content: event.tool_use_result.content, + toolUseId: event.tool_use_result.tool_use_id, + isError: event.tool_use_result.is_error || false, + timestamp: event.timestamp || Date.now() + }); + } + } catch (e) { + // Skip invalid JSON lines + } + } + + return messages; +} + +// Get session history for a project (supports SSH hosts) +app.get('/api/history/:project', async (req, res) => { try { const projectPath = decodeURIComponent(req.params.project); + const hostId = req.query.host; + const host = hostId ? hostsConfig.hosts[hostId] : null; + const isSSH = host?.connection?.type === 'ssh'; + // Convert project path to Claude's folder naming convention const projectFolder = projectPath.replace(/\//g, '-'); + + if (isSSH) { + // Load history via SSH + const { host: sshHost, user, port = 22 } = host.connection; + const sshTarget = `${user}@${sshHost}`; + const historyDir = `~/.claude/projects/${projectFolder}`; + + // Find latest session file via SSH + const findCmd = `ls -t ${historyDir}/*.jsonl 2>/dev/null | grep -v agent- | head -1`; + + const { execSync } = await import('child_process'); + try { + const latestFile = execSync(`ssh -T -o StrictHostKeyChecking=no -p ${port} ${sshTarget} "${findCmd}"`, { + encoding: 'utf-8', + timeout: 10000 + }).trim(); + + if (!latestFile) { + return res.json({ messages: [], sessionId: null }); + } + + // Read the last 2000 lines (to handle large history files) + const content = execSync(`ssh -T -o StrictHostKeyChecking=no -p ${port} ${sshTarget} "tail -n 2000 '${latestFile}'"`, { + encoding: 'utf-8', + timeout: 30000, + maxBuffer: 50 * 1024 * 1024 // 50MB buffer + }); + + const sessionId = basename(latestFile).replace('.jsonl', ''); + const messages = parseHistoryContent(content); + + return res.json({ messages, sessionId, source: 'ssh' }); + } catch (sshErr) { + console.error('SSH history fetch error:', sshErr.message); + return res.json({ messages: [], sessionId: null, error: sshErr.message }); + } + } + + // Local history const historyDir = `/home/node/.claude/projects/${projectFolder}`; if (!existsSync(historyDir)) { @@ -162,63 +275,7 @@ app.get('/api/history/:project', (req, res) => { const latestFile = files[0]; const sessionId = latestFile.name.replace('.jsonl', ''); const content = readFileSync(latestFile.path, 'utf-8'); - const lines = content.split('\n').filter(l => l.trim()); - - const messages = []; - for (const line of lines) { - try { - const event = JSON.parse(line); - - // Parse user messages - if (event.type === 'user' && event.message?.content) { - const textContent = event.message.content - .filter(c => c.type === 'text') - .map(c => c.text) - .join(''); - if (textContent && !event.tool_use_result) { - messages.push({ - type: 'user', - content: textContent, - timestamp: event.timestamp || Date.now() - }); - } - } - - // Parse assistant messages - if (event.type === 'assistant' && event.message?.content) { - for (const block of event.message.content) { - if (block.type === 'text' && block.text) { - messages.push({ - type: 'assistant', - content: block.text, - timestamp: event.timestamp || Date.now() - }); - } else if (block.type === 'tool_use') { - messages.push({ - type: 'tool_use', - tool: block.name, - input: block.input, - toolUseId: block.id, - timestamp: event.timestamp || Date.now() - }); - } - } - } - - // Parse tool results - if (event.type === 'user' && event.tool_use_result) { - messages.push({ - type: 'tool_result', - content: event.tool_use_result.content, - toolUseId: event.tool_use_result.tool_use_id, - isError: event.tool_use_result.is_error || false, - timestamp: event.timestamp || Date.now() - }); - } - } catch (e) { - // Skip invalid JSON lines - } - } + const messages = parseHistoryContent(content); res.json({ messages, sessionId }); } catch (err) { @@ -240,16 +297,21 @@ wss.on('connection', (ws, req) => { } }; - const startClaudeSession = (projectPath, resume = true) => { + const startClaudeSession = (projectPath, resume = true, hostId = null) => { if (claudeProcess) { console.log(`[${sessionId}] Killing existing Claude process`); claudeProcess.kill(); } currentProject = projectPath; - console.log(`[${sessionId}] Starting Claude in: ${projectPath} (resume: ${resume})`); - const args = [ + // Get host config + const host = hostId ? hostsConfig.hosts[hostId] : null; + const isSSH = host?.connection?.type === 'ssh'; + + console.log(`[${sessionId}] Starting Claude in: ${projectPath} (resume: ${resume}, host: ${hostId || 'local'}, ssh: ${isSSH})`); + + const claudeArgs = [ '-p', '--output-format', 'stream-json', '--input-format', 'stream-json', @@ -260,14 +322,38 @@ wss.on('connection', (ws, req) => { // Add continue flag to resume most recent conversation if (resume) { - args.push('--continue'); + claudeArgs.push('--continue'); } - claudeProcess = spawn('claude', args, { - stdio: ['pipe', 'pipe', 'pipe'], - cwd: projectPath || '/projects', - env: { ...process.env } - }); + if (isSSH) { + // SSH execution + const { host: sshHost, user, port = 22 } = host.connection; + const sshTarget = `${user}@${sshHost}`; + + // Build the remote command with PATH setup for non-login shells + const remoteCmd = `export PATH="$HOME/.local/bin:$PATH" && cd ${projectPath} && claude ${claudeArgs.join(' ')}`; + + console.log(`[${sessionId}] SSH to ${sshTarget}:${port} - ${remoteCmd}`); + + claudeProcess = spawn('ssh', [ + '-T', // Disable TTY (needed for JSON streaming) + '-o', 'StrictHostKeyChecking=no', + '-o', 'BatchMode=no', + '-p', String(port), + sshTarget, + remoteCmd + ], { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env } + }); + } else { + // Local execution + claudeProcess = spawn('claude', claudeArgs, { + stdio: ['pipe', 'pipe', 'pipe'], + cwd: projectPath || '/projects', + env: { ...process.env } + }); + } sessions.set(sessionId, { process: claudeProcess, project: projectPath }); @@ -329,7 +415,7 @@ wss.on('connection', (ws, req) => { switch (data.type) { case 'start_session': - startClaudeSession(data.project || '/projects', data.resume !== false); + startClaudeSession(data.project || '/projects', data.resume !== false, data.host || null); break; case 'user_message': diff --git a/docker-compose.yml b/docker-compose.yml index 457247f..9539159 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,9 @@ services: - ./config/.config/claude:/home/node/.config/claude:rw # Hosts configuration - ./config/hosts.json:/app/config/hosts.json:ro + # SSH keys for remote execution + - /home/sumdex/.ssh/id_rsa:/home/node/.ssh/id_rsa:ro + - /home/sumdex/.ssh/known_hosts:/home/node/.ssh/known_hosts:ro # Project directories for Claude to work in - /home/sumdex/projects:/projects:rw - /home/sumdex/docker:/docker:rw diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a9514a1..baff6d1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -101,6 +101,7 @@ function App() { } = useClaudeSession(); const [selectedProject, setSelectedProject] = useState('/projects/claude-web-ui'); + const [selectedHost, setSelectedHost] = useState('local'); const [sidebarOpen, setSidebarOpen] = useState(true); const [resumeSession, setResumeSession] = useState(true); @@ -114,7 +115,7 @@ function App() { }, [setMessages]); const handleStartSession = () => { - startSession(selectedProject, resumeSession); + startSession(selectedProject, resumeSession, selectedHost); }; // Handle slash commands @@ -167,6 +168,8 @@ function App() { onToggle={() => setSidebarOpen(!sidebarOpen)} selectedProject={selectedProject} onSelectProject={setSelectedProject} + selectedHost={selectedHost} + onSelectHost={setSelectedHost} sessionActive={sessionActive} onStartSession={handleStartSession} onStopSession={stopSession} diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 1b725a9..23c9c34 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { Play, Square, Trash2, FolderOpen, ChevronRight, Settings } from 'lucide-react'; +import { Play, Square, Trash2, FolderOpen, ChevronRight, Settings, Server } from 'lucide-react'; const API_URL = import.meta.env.VITE_API_URL || 'http://100.105.142.13:3001'; @@ -8,6 +8,8 @@ export function Sidebar({ onToggle, selectedProject, onSelectProject, + selectedHost, + onSelectHost, sessionActive, onStartSession, onStopSession, @@ -15,16 +17,39 @@ export function Sidebar({ resumeSession, onToggleResume }) { + const [hosts, setHosts] = useState([]); const [projects, setProjects] = useState([]); const [customPath, setCustomPath] = useState(''); + // Fetch hosts on mount useEffect(() => { - fetch(`${API_URL}/api/projects`) + fetch(`${API_URL}/api/hosts`) .then(res => res.json()) - .then(data => setProjects(data.projects || data)) + .then(data => { + setHosts(data.hosts || []); + if (!selectedHost && data.defaultHost) { + onSelectHost(data.defaultHost); + } + }) .catch(console.error); }, []); + // Fetch projects when host changes + useEffect(() => { + if (!selectedHost) return; + fetch(`${API_URL}/api/projects?host=${selectedHost}`) + .then(res => res.json()) + .then(data => { + const projectList = data.projects || data; + setProjects(projectList); + // Auto-select first project when host changes + if (projectList.length > 0) { + onSelectProject(projectList[0].path); + } + }) + .catch(console.error); + }, [selectedHost, onSelectProject]); + const handleCustomPath = () => { if (customPath.trim()) { onSelectProject(customPath.trim()); @@ -49,6 +74,36 @@ export function Sidebar({
+ {/* Host Selection */} +
+

+ Host +

+
+ {hosts.map((host) => ( + + ))} +
+
+ {/* Project Selection */}

diff --git a/frontend/src/hooks/useClaudeSession.js b/frontend/src/hooks/useClaudeSession.js index dbfa564..af21943 100644 --- a/frontend/src/hooks/useClaudeSession.js +++ b/frontend/src/hooks/useClaudeSession.js @@ -203,14 +203,15 @@ export function useClaudeSession() { } }, []); - const loadHistory = useCallback(async (project) => { + const loadHistory = useCallback(async (project, host = null) => { try { const encodedProject = encodeURIComponent(project); - const response = await fetch(`${API_URL}/api/history/${encodedProject}`); + const hostParam = host ? `?host=${host}` : ''; + const response = await fetch(`${API_URL}/api/history/${encodedProject}${hostParam}`); if (response.ok) { const data = await response.json(); if (data.messages && data.messages.length > 0) { - console.log(`Loaded ${data.messages.length} messages from history`); + console.log(`Loaded ${data.messages.length} messages from history (source: ${data.source || 'local'})`); setMessages(data.messages); return data.sessionId; } @@ -221,7 +222,7 @@ export function useClaudeSession() { return null; }, []); - const startSession = useCallback(async (project = '/projects', resume = true) => { + const startSession = useCallback(async (project = '/projects', resume = true, host = null) => { if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { setError('Not connected'); return; @@ -229,13 +230,14 @@ export function useClaudeSession() { // Load history before starting session if resuming if (resume) { - await loadHistory(project); + await loadHistory(project, host); } wsRef.current.send(JSON.stringify({ type: 'start_session', project, - resume + resume, + host })); }, [loadHistory]);