feat: Add SSH remote execution for multi-host Claude sessions

- 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 <noreply@anthropic.com>
This commit is contained in:
2025-12-15 22:59:34 +01:00
parent 60095d6e25
commit 9eb0ecfb57
6 changed files with 236 additions and 83 deletions

View File

@@ -1,13 +1,17 @@
FROM node:20-slim 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 # Create app directory
WORKDIR /app 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 && \ RUN mkdir -p /home/node/.local/bin && \
mkdir -p /home/node/.local/share/claude && \ mkdir -p /home/node/.local/share/claude && \
mkdir -p /home/node/.claude && \ mkdir -p /home/node/.claude && \
mkdir -p /home/node/.config/claude && \ mkdir -p /home/node/.config/claude && \
mkdir -p /home/node/.ssh && \
chown -R node:node /home/node chown -R node:node /home/node
# Create symlink for claude binary # Create symlink for claude binary

View File

@@ -91,12 +91,19 @@ app.get('/api/projects', (req, res) => {
return res.status(404).json({ error: `Host '${hostId}' not found` }); 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') { if (host.connection.type !== 'local') {
const projects = host.basePaths.map(basePath => ({
path: basePath,
name: basename(basePath),
type: 'base',
isBase: true
}));
return res.json({ return res.json({
projects: [], projects,
host: hostId, 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 // Parse history content into messages
app.get('/api/history/:project', (req, res) => { 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 { try {
const projectPath = decodeURIComponent(req.params.project); 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 // Convert project path to Claude's folder naming convention
const projectFolder = projectPath.replace(/\//g, '-'); 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}`; const historyDir = `/home/node/.claude/projects/${projectFolder}`;
if (!existsSync(historyDir)) { if (!existsSync(historyDir)) {
@@ -162,63 +275,7 @@ app.get('/api/history/:project', (req, res) => {
const latestFile = files[0]; const latestFile = files[0];
const sessionId = latestFile.name.replace('.jsonl', ''); const sessionId = latestFile.name.replace('.jsonl', '');
const content = readFileSync(latestFile.path, 'utf-8'); const content = readFileSync(latestFile.path, 'utf-8');
const lines = content.split('\n').filter(l => l.trim()); const messages = parseHistoryContent(content);
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
}
}
res.json({ messages, sessionId }); res.json({ messages, sessionId });
} catch (err) { } 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) { if (claudeProcess) {
console.log(`[${sessionId}] Killing existing Claude process`); console.log(`[${sessionId}] Killing existing Claude process`);
claudeProcess.kill(); claudeProcess.kill();
} }
currentProject = projectPath; 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', '-p',
'--output-format', 'stream-json', '--output-format', 'stream-json',
'--input-format', 'stream-json', '--input-format', 'stream-json',
@@ -260,14 +322,38 @@ wss.on('connection', (ws, req) => {
// Add continue flag to resume most recent conversation // Add continue flag to resume most recent conversation
if (resume) { if (resume) {
args.push('--continue'); claudeArgs.push('--continue');
} }
claudeProcess = spawn('claude', args, { if (isSSH) {
stdio: ['pipe', 'pipe', 'pipe'], // SSH execution
cwd: projectPath || '/projects', const { host: sshHost, user, port = 22 } = host.connection;
env: { ...process.env } 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 }); sessions.set(sessionId, { process: claudeProcess, project: projectPath });
@@ -329,7 +415,7 @@ wss.on('connection', (ws, req) => {
switch (data.type) { switch (data.type) {
case 'start_session': case 'start_session':
startClaudeSession(data.project || '/projects', data.resume !== false); startClaudeSession(data.project || '/projects', data.resume !== false, data.host || null);
break; break;
case 'user_message': case 'user_message':

View File

@@ -14,6 +14,9 @@ services:
- ./config/.config/claude:/home/node/.config/claude:rw - ./config/.config/claude:/home/node/.config/claude:rw
# Hosts configuration # Hosts configuration
- ./config/hosts.json:/app/config/hosts.json:ro - ./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 # Project directories for Claude to work in
- /home/sumdex/projects:/projects:rw - /home/sumdex/projects:/projects:rw
- /home/sumdex/docker:/docker:rw - /home/sumdex/docker:/docker:rw

View File

@@ -101,6 +101,7 @@ function App() {
} = useClaudeSession(); } = useClaudeSession();
const [selectedProject, setSelectedProject] = useState('/projects/claude-web-ui'); const [selectedProject, setSelectedProject] = useState('/projects/claude-web-ui');
const [selectedHost, setSelectedHost] = useState('local');
const [sidebarOpen, setSidebarOpen] = useState(true); const [sidebarOpen, setSidebarOpen] = useState(true);
const [resumeSession, setResumeSession] = useState(true); const [resumeSession, setResumeSession] = useState(true);
@@ -114,7 +115,7 @@ function App() {
}, [setMessages]); }, [setMessages]);
const handleStartSession = () => { const handleStartSession = () => {
startSession(selectedProject, resumeSession); startSession(selectedProject, resumeSession, selectedHost);
}; };
// Handle slash commands // Handle slash commands
@@ -167,6 +168,8 @@ function App() {
onToggle={() => setSidebarOpen(!sidebarOpen)} onToggle={() => setSidebarOpen(!sidebarOpen)}
selectedProject={selectedProject} selectedProject={selectedProject}
onSelectProject={setSelectedProject} onSelectProject={setSelectedProject}
selectedHost={selectedHost}
onSelectHost={setSelectedHost}
sessionActive={sessionActive} sessionActive={sessionActive}
onStartSession={handleStartSession} onStartSession={handleStartSession}
onStopSession={stopSession} onStopSession={stopSession}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; 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'; const API_URL = import.meta.env.VITE_API_URL || 'http://100.105.142.13:3001';
@@ -8,6 +8,8 @@ export function Sidebar({
onToggle, onToggle,
selectedProject, selectedProject,
onSelectProject, onSelectProject,
selectedHost,
onSelectHost,
sessionActive, sessionActive,
onStartSession, onStartSession,
onStopSession, onStopSession,
@@ -15,16 +17,39 @@ export function Sidebar({
resumeSession, resumeSession,
onToggleResume onToggleResume
}) { }) {
const [hosts, setHosts] = useState([]);
const [projects, setProjects] = useState([]); const [projects, setProjects] = useState([]);
const [customPath, setCustomPath] = useState(''); const [customPath, setCustomPath] = useState('');
// Fetch hosts on mount
useEffect(() => { useEffect(() => {
fetch(`${API_URL}/api/projects`) fetch(`${API_URL}/api/hosts`)
.then(res => res.json()) .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); .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 = () => { const handleCustomPath = () => {
if (customPath.trim()) { if (customPath.trim()) {
onSelectProject(customPath.trim()); onSelectProject(customPath.trim());
@@ -49,6 +74,36 @@ export function Sidebar({
</div> </div>
<div className="flex-1 overflow-y-auto p-4 space-y-6"> <div className="flex-1 overflow-y-auto p-4 space-y-6">
{/* Host Selection */}
<div className="space-y-3">
<h3 className="text-sm font-medium text-dark-400 uppercase tracking-wide">
Host
</h3>
<div className="flex gap-2 flex-wrap">
{hosts.map((host) => (
<button
key={host.id}
onClick={() => onSelectHost(host.id)}
className={`
flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
transition-colors border
${selectedHost === host.id
? 'border-orange-500/50 text-white'
: 'border-dark-700 text-dark-400 hover:border-dark-600 hover:text-dark-300'
}
`}
style={{
backgroundColor: selectedHost === host.id ? `${host.color}30` : 'transparent'
}}
>
<Server className="w-3.5 h-3.5" style={{ color: host.color }} />
<span>{host.name}</span>
{!host.isLocal && <span className="text-xs text-dark-500">(SSH)</span>}
</button>
))}
</div>
</div>
{/* Project Selection */} {/* Project Selection */}
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-sm font-medium text-dark-400 uppercase tracking-wide"> <h3 className="text-sm font-medium text-dark-400 uppercase tracking-wide">

View File

@@ -203,14 +203,15 @@ export function useClaudeSession() {
} }
}, []); }, []);
const loadHistory = useCallback(async (project) => { const loadHistory = useCallback(async (project, host = null) => {
try { try {
const encodedProject = encodeURIComponent(project); 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) { if (response.ok) {
const data = await response.json(); const data = await response.json();
if (data.messages && data.messages.length > 0) { 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); setMessages(data.messages);
return data.sessionId; return data.sessionId;
} }
@@ -221,7 +222,7 @@ export function useClaudeSession() {
return null; 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) { if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
setError('Not connected'); setError('Not connected');
return; return;
@@ -229,13 +230,14 @@ export function useClaudeSession() {
// Load history before starting session if resuming // Load history before starting session if resuming
if (resume) { if (resume) {
await loadHistory(project); await loadHistory(project, host);
} }
wsRef.current.send(JSON.stringify({ wsRef.current.send(JSON.stringify({
type: 'start_session', type: 'start_session',
project, project,
resume resume,
host
})); }));
}, [loadHistory]); }, [loadHistory]);