Add agent command execution (terminal panel)

- DB: new agent_commands table (id, agent_id, command, status, output, exit_code, timestamps)
- Server: 4 new routes
    POST /api/agents/:id/commands        queue a command (dashboard → agent)
    GET  /api/agents/:id/commands        history (last 50) for dashboard
    GET  /api/agents/commands/pending    agent dequeues + marks running (Bearer auth)
    POST /api/agents/commands/:id/result agent posts output/exit_code (Bearer auth)
  Output capped at 128 KB; Bearer token enforced on agent-facing routes
- Agents (Linux/macOS/Windows): each polls /commands/pending each cycle,
  executes via bash/PowerShell with 60s timeout, posts result back
- UI: ">_" button on each AgentCard opens CommandTerminal panel
    Input line, Run button, history list (status icon + command + time)
    Click any entry with output to expand/collapse its output
    Auto-refreshes every 4 s while open

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-08-11 17:05:20 -04:00
1 parent 13b3e3612b
commit ce4bea5e29
15 files changed
+543 -15

No files matched your search

+123
View File
@@ -16,6 +16,27 @@ const metrics = require('./metrics');
const { discoverNetwork, getLocalSubnets } = require('./networkDiscovery');
const auditLog = require('./auditLog');
// Initialize agent_commands table
try {
db.exec(`
CREATE TABLE IF NOT EXISTS agent_commands (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id INTEGER NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
command TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
output TEXT,
exit_code INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
started_at DATETIME,
completed_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_agent_commands_agent ON agent_commands(agent_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_agent_commands_status ON agent_commands(agent_id, status);
`);
} catch (error) {
console.error('Error initializing agent_commands table:', error);
}
// Initialize notifications table if it doesn't exist
try {
db.exec(`
@@ -2408,6 +2429,108 @@ app.delete('/api/agents/:id', (req, res) => {
}
});
// ── Agent command queue ────────────────────────────────────────────────────────
// Dashboard → queue a command for a specific agent
app.post('/api/agents/:id/commands', (req, res) => {
const agentId = parseInt(req.params.id, 10);
const { command } = req.body;
if (!command || typeof command !== 'string' || !command.trim()) {
return res.status(400).json({ error: 'command is required' });
}
const agent = db.prepare('SELECT id FROM agents WHERE id = ?').get(agentId);
if (!agent) return res.status(404).json({ error: 'Agent not found' });
try {
const result = db.prepare(
`INSERT INTO agent_commands (agent_id, command) VALUES (?, ?)`
).run(agentId, command.trim());
res.json({ id: result.lastInsertRowid, status: 'pending' });
} catch (err) {
res.status(500).json({ error: 'Failed to queue command' });
}
});
// Dashboard → get command history for an agent (last 50)
app.get('/api/agents/:id/commands', (req, res) => {
const agentId = parseInt(req.params.id, 10);
try {
const cmds = db.prepare(`
SELECT id, command, status, output, exit_code,
REPLACE(created_at, ' ', 'T') || 'Z' AS created_at,
REPLACE(started_at, ' ', 'T') || 'Z' AS started_at,
REPLACE(completed_at, ' ', 'T') || 'Z' AS completed_at
FROM agent_commands
WHERE agent_id = ?
ORDER BY created_at DESC
LIMIT 50
`).all(agentId);
res.json({ commands: cmds });
} catch (err) {
res.status(500).json({ error: 'Failed to fetch commands' });
}
});
// Agent → dequeue one pending command (marks it running; returns 204 if queue empty)
app.get('/api/agents/commands/pending', (req, res) => {
const auth = req.headers.authorization || '';
const token = auth.startsWith('Bearer ') ? auth.slice(7) : auth;
if (!token) return res.status(401).json({ error: 'Authorization required' });
const agent = db.prepare('SELECT id FROM agents WHERE token = ?').get(token);
if (!agent) return res.status(401).json({ error: 'Invalid token' });
try {
const cmd = db.prepare(`
SELECT id, command FROM agent_commands
WHERE agent_id = ? AND status = 'pending'
ORDER BY created_at ASC
LIMIT 1
`).get(agent.id);
if (!cmd) return res.status(204).end();
db.prepare(
`UPDATE agent_commands SET status = 'running', started_at = CURRENT_TIMESTAMP WHERE id = ?`
).run(cmd.id);
res.json({ id: cmd.id, command: cmd.command });
} catch (err) {
res.status(500).json({ error: 'Failed to dequeue command' });
}
});
// Agent → post result for a completed command
app.post('/api/agents/commands/:cmdId/result', (req, res) => {
const auth = req.headers.authorization || '';
const token = auth.startsWith('Bearer ') ? auth.slice(7) : auth;
if (!token) return res.status(401).json({ error: 'Authorization required' });
const agent = db.prepare('SELECT id FROM agents WHERE token = ?').get(token);
if (!agent) return res.status(401).json({ error: 'Invalid token' });
const cmdId = parseInt(req.params.cmdId, 10);
const cmd = db.prepare('SELECT id, agent_id FROM agent_commands WHERE id = ?').get(cmdId);
if (!cmd || cmd.agent_id !== agent.id) return res.status(404).json({ error: 'Command not found' });
const { output, exit_code } = req.body;
const status = (exit_code === 0 || exit_code === '0') ? 'completed' : 'failed';
// Cap output at 128 KB
const safeOutput = typeof output === 'string' ? output.slice(0, 131072) : '';
try {
db.prepare(`
UPDATE agent_commands
SET status = ?, output = ?, exit_code = ?, completed_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(status, safeOutput, parseInt(exit_code) ?? -1, cmdId);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Failed to store result' });
}
});
// Serve React app with proper MIME types
const mimeTypes = {
'.js': 'application/javascript',