Skip to content

Commit 38dbeea

Browse files
committed
feat: embed agent-runner into Vite dev server — zero manual steps
- Created vite-plugin-agent-runner.js: Docker exec API as Vite middleware - npm run dev now serves both website AND /api/exec on same port (8877) - No separate 'node server.js' needed — health check, Docker build, container lifecycle, CLI wrapping all handled by Vite plugin - agent-cloud.js: uses window.location.origin instead of hardcoded :8080 - Added health check + auto-start toast for graceful error handling - 24 Playwright tests pass
1 parent 67c7559 commit 38dbeea

File tree

3 files changed

+345
-2
lines changed

3 files changed

+345
-2
lines changed

js/agent-cloud.js

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,16 @@
4646
run: async function (command, opts) {
4747
opts = opts || {};
4848

49+
var provider = M.agentCloud.getProvider() || 'local';
50+
console.log('%c[AgentCloud] ▶ run()', 'color: #58a6ff; font-weight: bold',
51+
'| provider=' + provider,
52+
'| agent=' + (opts.agentType || 'none'),
53+
'| forceLocal=' + !!opts.forceLocal,
54+
'| cmd=' + command.substring(0, 80));
55+
4956
// Local agent execution (no GitHub auth needed)
5057
if (opts.forceLocal) {
58+
console.log('[AgentCloud] → Using LOCAL Docker endpoint');
5159
return runCustomEndpoint(command, opts);
5260
}
5361

@@ -289,7 +297,31 @@
289297

290298
/** Custom/local endpoint (Docker-backed, E2B, Daytona, self-hosted) */
291299
async function runCustomEndpoint(command, opts) {
292-
var url = localStorage.getItem(M.KEYS.AGENT_CUSTOM_URL) || 'http://localhost:8080/api/exec';
300+
var url = localStorage.getItem(M.KEYS.AGENT_CUSTOM_URL) || (window.location.origin + '/api/exec');
301+
var baseUrl = url.replace(/\/api\/exec$/, '');
302+
303+
// ── Health check: auto-detect if server is running ──
304+
var serverReady = await checkServerHealth(baseUrl);
305+
if (!serverReady) {
306+
// Try to auto-start the server
307+
var started = await tryAutoStartServer(baseUrl);
308+
if (!started) {
309+
throw new Error(
310+
'Agent runner is not running.\n\n' +
311+
'Start it with:\n cd agent-runner && node server.js\n\n' +
312+
'Then click ▶ Run again.'
313+
);
314+
}
315+
}
316+
317+
console.log('%c[AgentCloud] ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', 'color: #3fb950; font-weight: bold');
318+
console.log('%c[AgentCloud] 📨 Sending request to agent runner', 'color: #3fb950');
319+
console.log('[AgentCloud] URL:', url);
320+
console.log('[AgentCloud] Agent:', opts.agentType || '(none)');
321+
console.log('[AgentCloud] Command:', command.substring(0, 200));
322+
console.log('[AgentCloud] Context:', opts.prevOutput ? opts.prevOutput.length + ' chars' : '(none)');
323+
324+
var startTime = performance.now();
293325

294326
var res = await fetch(url, {
295327
method: 'POST',
@@ -301,13 +333,87 @@
301333
})
302334
});
303335

336+
var elapsed = Math.round(performance.now() - startTime);
337+
304338
if (!res.ok) {
305339
var errBody = '';
306340
try { errBody = await res.text(); } catch (_) {}
341+
console.error('[AgentCloud] ❌ Error ' + res.status + ' after ' + elapsed + 'ms:', errBody.substring(0, 200));
342+
console.log('%c[AgentCloud] ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', 'color: #f85149; font-weight: bold');
307343
throw new Error('Agent exec error (' + res.status + '): ' + errBody.substring(0, 200));
308344
}
309345

310-
return await res.json();
346+
var result = await res.json();
347+
console.log('%c[AgentCloud] ✅ Response received in ' + elapsed + 'ms', 'color: #3fb950');
348+
console.log('[AgentCloud] Exit code:', result.exitCode);
349+
console.log('[AgentCloud] Stdout:', (result.stdout || '').substring(0, 300) || '(empty)');
350+
if (result.stderr) console.warn('[AgentCloud] Stderr:', result.stderr.substring(0, 300));
351+
console.log('%c[AgentCloud] ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', 'color: #3fb950; font-weight: bold');
352+
353+
return result;
354+
}
355+
356+
/** Quick health check — returns true if server responds */
357+
async function checkServerHealth(baseUrl) {
358+
try {
359+
var res = await fetch(baseUrl + '/health', {
360+
method: 'GET',
361+
signal: AbortSignal.timeout(2000)
362+
});
363+
return res.ok;
364+
} catch (_) {
365+
return false;
366+
}
367+
}
368+
369+
/** Try to auto-start the agent-runner server */
370+
async function tryAutoStartServer(baseUrl) {
371+
console.log('[AgentCloud] 🚀 Agent runner not detected — attempting auto-start...');
372+
373+
if (M.showToast) {
374+
M.showToast('🚀 Starting agent runner...', 'info');
375+
}
376+
377+
// Try spawning via the native Neutralino API (desktop app)
378+
if (window.Neutralino && Neutralino.os && Neutralino.os.execCommand) {
379+
try {
380+
Neutralino.os.execCommand('cd agent-runner && node server.js &', { background: true });
381+
// Wait for server to come up
382+
return await waitForServer(baseUrl, 15000);
383+
} catch (e) {
384+
console.warn('[AgentCloud] Neutralino exec failed:', e);
385+
}
386+
}
387+
388+
// If running in a Codespace / Gitpod / devcontainer, try fetch to terminal API
389+
// This is a fallback for environments that support it
390+
391+
// Show helpful error with command
392+
if (M.showToast) {
393+
M.showToast(
394+
'⚠️ Agent runner not running — run: cd agent-runner && node server.js',
395+
'warning',
396+
8000
397+
);
398+
}
399+
400+
// Give a brief moment in case it was just starting up
401+
return await waitForServer(baseUrl, 5000);
402+
}
403+
404+
/** Poll for server readiness */
405+
async function waitForServer(baseUrl, maxWaitMs) {
406+
var start = Date.now();
407+
while (Date.now() - start < maxWaitMs) {
408+
var ready = await checkServerHealth(baseUrl);
409+
if (ready) {
410+
console.log('[AgentCloud] ✅ Agent runner is ready');
411+
if (M.showToast) M.showToast('✅ Agent runner connected', 'info');
412+
return true;
413+
}
414+
await new Promise(function (r) { setTimeout(r, 1000); });
415+
}
416+
return false;
311417
}
312418

313419
})(window.MDView = window.MDView || {});

vite-plugin-agent-runner.js

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
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+
}

vite.config.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import { defineConfig } from 'vite';
22
import { resolve } from 'path';
3+
import agentRunnerPlugin from './vite-plugin-agent-runner.js';
34

45
export default defineConfig({
56
root: '.',
67
publicDir: 'public',
78

9+
plugins: [
10+
agentRunnerPlugin()
11+
],
12+
813
build: {
914
outDir: 'dist',
1015
emptyOutDir: true,

0 commit comments

Comments
 (0)