|
| 1 | +/** |
| 2 | + * vite-plugin-agent-runner.js — Vite dev server middleware |
| 3 | + * |
| 4 | + * Embeds the agent-runner Docker exec API directly into the Vite dev server |
| 5 | + * so that `npm run dev` automatically serves both the website AND the |
| 6 | + * /api/exec endpoint — zero separate server needed. |
| 7 | + * |
| 8 | + * Endpoints added: |
| 9 | + * POST /api/exec { command, agentType?, context? } |
| 10 | + * GET /health |
| 11 | + */ |
| 12 | +import { exec, execSync } from 'child_process'; |
| 13 | +import { existsSync, readdirSync } from 'fs'; |
| 14 | +import { join, dirname } from 'path'; |
| 15 | +import { fileURLToPath } from 'url'; |
| 16 | + |
| 17 | +const __dirname_plugin = dirname(fileURLToPath(import.meta.url)); |
| 18 | + |
| 19 | +const MAX_OUTPUT = 64 * 1024; |
| 20 | +const TIMEOUT_MS = 120 * 1000; |
| 21 | +const IDLE_STOP_MS = 10 * 60 * 1000; |
| 22 | +const CONTAINER_PREFIX = 'textagent-agent-'; |
| 23 | +const IMAGE_PREFIX = 'textagent/'; |
| 24 | + |
| 25 | +const AGENT_CLI_MAP = { |
| 26 | + openclaw: { |
| 27 | + bin: 'openclaw', |
| 28 | + buildCmd: (message) => { |
| 29 | + return ['openclaw', 'agent', '--message', JSON.stringify(message), '--json', '--timeout', '90'].join(' '); |
| 30 | + } |
| 31 | + }, |
| 32 | + openfang: { |
| 33 | + bin: 'openfang', |
| 34 | + buildCmd: (message) => { |
| 35 | + return ['openfang', 'agent', '--message', JSON.stringify(message), '--json', '--timeout', '90'].join(' '); |
| 36 | + } |
| 37 | + } |
| 38 | +}; |
| 39 | + |
| 40 | +const FORWARDED_ENV_KEYS = [ |
| 41 | + 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'GOOGLE_API_KEY', |
| 42 | + 'GOOGLE_GENERATIVE_AI_API_KEY', 'GROQ_API_KEY', 'MISTRAL_API_KEY', |
| 43 | + 'TOGETHER_API_KEY', 'OPENROUTER_API_KEY', 'DEEPSEEK_API_KEY', 'XAI_API_KEY' |
| 44 | +]; |
| 45 | + |
| 46 | +const containers = {}; |
| 47 | +const idleTimers = {}; |
| 48 | + |
| 49 | +function log(...args) { |
| 50 | + const ts = new Date().toLocaleTimeString('en-GB', { hour12: false }); |
| 51 | + console.log(`[AgentRunner ${ts}]`, ...args); |
| 52 | +} |
| 53 | + |
| 54 | +function isDockerAvailable() { |
| 55 | + try { execSync('docker info', { stdio: 'ignore', timeout: 5000 }); return true; } |
| 56 | + catch { return false; } |
| 57 | +} |
| 58 | + |
| 59 | +function imageExists(agentType) { |
| 60 | + try { |
| 61 | + return execSync(`docker images -q ${IMAGE_PREFIX}${agentType}`, { encoding: 'utf8', timeout: 5000 }).trim().length > 0; |
| 62 | + } catch { return false; } |
| 63 | +} |
| 64 | + |
| 65 | +function buildImage(agentType) { |
| 66 | + const agentDir = join(__dirname_plugin, 'agent-runner', 'agents', agentType); |
| 67 | + if (!existsSync(join(agentDir, 'Dockerfile'))) throw new Error(`No Dockerfile for "${agentType}"`); |
| 68 | + |
| 69 | + log(`🔨 Building image ${IMAGE_PREFIX}${agentType}...`); |
| 70 | + execSync(`docker build -t ${IMAGE_PREFIX}${agentType} ${agentDir}`, { |
| 71 | + encoding: 'utf8', timeout: 300000, stdio: ['pipe', 'pipe', 'pipe'] |
| 72 | + }); |
| 73 | + log(`✅ Image ${IMAGE_PREFIX}${agentType} built`); |
| 74 | +} |
| 75 | + |
| 76 | +function isContainerRunning(name) { |
| 77 | + try { |
| 78 | + return execSync(`docker inspect -f '{{.State.Running}}' ${name} 2>/dev/null`, { encoding: 'utf8', timeout: 5000 }).trim() === 'true'; |
| 79 | + } catch { return false; } |
| 80 | +} |
| 81 | + |
| 82 | +function ensureContainer(agentType) { |
| 83 | + const name = CONTAINER_PREFIX + agentType; |
| 84 | + if (isContainerRunning(name)) { containers[agentType] = name; return name; } |
| 85 | + |
| 86 | + try { execSync(`docker rm -f ${name} 2>/dev/null`, { stdio: 'ignore', timeout: 5000 }); } catch {} |
| 87 | + |
| 88 | + if (!imageExists(agentType)) buildImage(agentType); |
| 89 | + |
| 90 | + const envFlags = FORWARDED_ENV_KEYS.filter(k => process.env[k]).map(k => `-e ${k}=${process.env[k]}`).join(' '); |
| 91 | + log(`🚀 Starting container ${name}...`); |
| 92 | + execSync(`docker run -d --name ${name} ${envFlags} ${IMAGE_PREFIX}${agentType}`, { encoding: 'utf8', timeout: 30000 }); |
| 93 | + containers[agentType] = name; |
| 94 | + log(`✅ Container ${name} started`); |
| 95 | + return name; |
| 96 | +} |
| 97 | + |
| 98 | +function dockerExec(containerName, command, timeoutMs) { |
| 99 | + return new Promise((resolve) => { |
| 100 | + const safeCmd = command.replace(/'/g, "'\\''"); |
| 101 | + exec(`docker exec ${containerName} bash -c '${safeCmd}'`, { |
| 102 | + timeout: timeoutMs, maxBuffer: MAX_OUTPUT, env: { ...process.env, TERM: 'dumb' } |
| 103 | + }, (error, stdout, stderr) => { |
| 104 | + resolve({ |
| 105 | + stdout: (stdout || '').substring(0, MAX_OUTPUT), |
| 106 | + stderr: (stderr || '').substring(0, MAX_OUTPUT), |
| 107 | + exitCode: error ? (error.code || 1) : 0 |
| 108 | + }); |
| 109 | + }); |
| 110 | + }); |
| 111 | +} |
| 112 | + |
| 113 | +function stopContainer(agentType) { |
| 114 | + const name = containers[agentType]; |
| 115 | + if (!name) return; |
| 116 | + try { execSync(`docker stop ${name}`, { timeout: 15000, stdio: 'ignore' }); } catch {} |
| 117 | + try { execSync(`docker rm ${name}`, { timeout: 5000, stdio: 'ignore' }); } catch {} |
| 118 | + delete containers[agentType]; delete idleTimers[agentType]; |
| 119 | +} |
| 120 | + |
| 121 | +function resetIdleTimer(agentType) { |
| 122 | + if (idleTimers[agentType]) clearTimeout(idleTimers[agentType]); |
| 123 | + idleTimers[agentType] = setTimeout(() => { log(`⏰ Idle timeout for ${agentType}`); stopContainer(agentType); }, IDLE_STOP_MS); |
| 124 | +} |
| 125 | + |
| 126 | +function hostExec(command, timeoutMs) { |
| 127 | + return new Promise((resolve) => { |
| 128 | + exec(command, { timeout: timeoutMs, maxBuffer: MAX_OUTPUT, env: { ...process.env, TERM: 'dumb' } }, |
| 129 | + (error, stdout, stderr) => { |
| 130 | + resolve({ stdout: (stdout || '').substring(0, MAX_OUTPUT), stderr: (stderr || '').substring(0, MAX_OUTPUT), exitCode: error ? (error.code || 1) : 0 }); |
| 131 | + }); |
| 132 | + }); |
| 133 | +} |
| 134 | + |
| 135 | +// ── Vite Plugin ── |
| 136 | + |
| 137 | +export default function agentRunnerPlugin() { |
| 138 | + return { |
| 139 | + name: 'agent-runner', |
| 140 | + configureServer(server) { |
| 141 | + const agentDir = join(__dirname_plugin, 'agent-runner', 'agents'); |
| 142 | + const agents = existsSync(agentDir) ? readdirSync(agentDir).filter(d => existsSync(join(agentDir, d, 'Dockerfile'))) : []; |
| 143 | + const docker = isDockerAvailable(); |
| 144 | + log(`🐳 Docker: ${docker ? 'available' : '⚠ NOT AVAILABLE'}`); |
| 145 | + log(`📂 Agents: ${agents.join(', ') || 'none'}`); |
| 146 | + |
| 147 | + // Health check |
| 148 | + server.middlewares.use('/health', (req, res) => { |
| 149 | + res.setHeader('Content-Type', 'application/json'); |
| 150 | + res.end(JSON.stringify({ status: 'ok', uptime: process.uptime(), docker, activeAgents: Object.keys(containers) })); |
| 151 | + }); |
| 152 | + |
| 153 | + // Exec endpoint |
| 154 | + server.middlewares.use('/api/exec', (req, res) => { |
| 155 | + if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } |
| 156 | + if (req.method !== 'POST') { res.writeHead(405); res.end('Method not allowed'); return; } |
| 157 | + |
| 158 | + let body = ''; |
| 159 | + req.on('data', chunk => { body += chunk; if (body.length > 1024 * 1024) { res.writeHead(413); res.end('Too large'); req.destroy(); } }); |
| 160 | + req.on('end', async () => { |
| 161 | + log('📨 POST /api/exec'); |
| 162 | + let parsed; |
| 163 | + try { parsed = JSON.parse(body); } catch { res.writeHead(400); res.end(JSON.stringify({ error: 'Invalid JSON' })); return; } |
| 164 | + |
| 165 | + const command = parsed.command; |
| 166 | + const agentType = (parsed.agentType || '').trim().toLowerCase(); |
| 167 | + |
| 168 | + if (!command || typeof command !== 'string') { res.writeHead(400); res.end(JSON.stringify({ error: 'Missing "command"' })); return; } |
| 169 | + |
| 170 | + // Safety checks |
| 171 | + const blocked = [/rm\s+-rf\s+\/(?!\w)/i, /mkfs/i, /dd\s+if=/i, /:(){ :|:& };:/]; |
| 172 | + for (const p of blocked) { if (p.test(command)) { res.writeHead(403); res.end(JSON.stringify({ error: 'Command blocked' })); return; } } |
| 173 | + |
| 174 | + try { |
| 175 | + let result; |
| 176 | + if (agentType) { |
| 177 | + if (!isDockerAvailable()) { |
| 178 | + res.setHeader('Content-Type', 'application/json'); |
| 179 | + res.writeHead(500); |
| 180 | + res.end(JSON.stringify({ error: 'Docker not available. Install Docker Desktop.', stdout: '', stderr: 'Docker not running', exitCode: 1 })); |
| 181 | + return; |
| 182 | + } |
| 183 | + if (!/^[a-z0-9-]+$/.test(agentType)) { res.writeHead(400); res.end(JSON.stringify({ error: 'Invalid agent type' })); return; } |
| 184 | + |
| 185 | + const dockerfile = join(__dirname_plugin, 'agent-runner', 'agents', agentType, 'Dockerfile'); |
| 186 | + if (!existsSync(dockerfile)) { res.writeHead(404); res.end(JSON.stringify({ error: `Unknown agent "${agentType}"` })); return; } |
| 187 | + |
| 188 | + const containerName = ensureContainer(agentType); |
| 189 | + const agentCli = AGENT_CLI_MAP[agentType]; |
| 190 | + let execCommand; |
| 191 | + |
| 192 | + if (agentCli) { |
| 193 | + let msg = command; |
| 194 | + if (parsed.context) msg = 'Context from previous steps:\n' + parsed.context.substring(0, 3000) + '\n\nCurrent task: ' + command; |
| 195 | + execCommand = agentCli.buildCmd(msg); |
| 196 | + log(`🤖 ${agentCli.bin} CLI: ${execCommand.substring(0, 200)}`); |
| 197 | + } else { |
| 198 | + execCommand = command; |
| 199 | + } |
| 200 | + |
| 201 | + const rawResult = await dockerExec(containerName, execCommand, TIMEOUT_MS); |
| 202 | + |
| 203 | + if (agentCli && rawResult.stdout) { |
| 204 | + try { |
| 205 | + const resp = JSON.parse(rawResult.stdout); |
| 206 | + const reply = resp.reply || resp.text || resp.message || resp.output || rawResult.stdout; |
| 207 | + result = { stdout: typeof reply === 'string' ? reply : JSON.stringify(reply, null, 2), stderr: rawResult.stderr, exitCode: rawResult.exitCode }; |
| 208 | + } catch { result = rawResult; } |
| 209 | + } else { result = rawResult; } |
| 210 | + |
| 211 | + resetIdleTimer(agentType); |
| 212 | + } else { |
| 213 | + result = await hostExec(command, TIMEOUT_MS); |
| 214 | + } |
| 215 | + |
| 216 | + log(`📤 Response sent — exit=${result.exitCode}`); |
| 217 | + res.setHeader('Content-Type', 'application/json'); |
| 218 | + res.end(JSON.stringify(result)); |
| 219 | + } catch (err) { |
| 220 | + log(`❌ ${err.message}`); |
| 221 | + res.setHeader('Content-Type', 'application/json'); |
| 222 | + res.writeHead(500); |
| 223 | + res.end(JSON.stringify({ error: err.message, stdout: '', stderr: err.message, exitCode: 1 })); |
| 224 | + } |
| 225 | + }); |
| 226 | + }); |
| 227 | + |
| 228 | + // Cleanup on server close |
| 229 | + server.httpServer?.on('close', () => { Object.keys(containers).forEach(stopContainer); }); |
| 230 | + } |
| 231 | + }; |
| 232 | +} |
0 commit comments