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:
@@ -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
|
||||
|
||||
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user