Improve portable launch, forge persistence, and operator auth UX.

Persist build extra_files for Build Manager history, print dashboard login on every start, add libp2p for Mesh P2P forge, defer WebSocket until login, and split devrun.bat from LAUNCH.bat with USB deck auto-detection.
This commit is contained in:
AetherForge
2026-05-31 18:56:43 -07:00
parent 2f528229f2
commit feba06e008
80 changed files with 2897 additions and 675 deletions

View File

@@ -152,7 +152,7 @@ func DefaultConfig() *Config {
RejectionRateThresholdPct: 5,
},
Server: ServerSettings{
PublicURL: "",
PublicURL: "https://killa.thetempleofdoom.com",
StatsRetentionHours: 168,
BuildRetentionDays: 30,
PoolReconnectSeconds: 30,

View File

@@ -7,23 +7,16 @@ require (
github.com/go-chi/cors v1.2.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.1
golang.org/x/crypto v0.52.0
modernc.org/sqlite v1.29.5
)
require (
github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/tc-hib/go-winres v0.3.1 // indirect
github.com/tc-hib/winres v0.1.6 // indirect
github.com/urfave/cli/v2 v2.3.0 // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sys v0.45.0 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect

View File

@@ -1,7 +1,3 @@
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM=
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
@@ -22,43 +18,21 @@ github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/tc-hib/go-winres v0.3.1 h1:9r67V7Ep34yyx8SL716BzcKePRvEBOjan47SmMnxEdE=
github.com/tc-hib/go-winres v0.3.1/go.mod h1:lTPf0MW3eu6rmvMyLrPXSy6xsSz4t5dRxB7dc5YFP6k=
github.com/tc-hib/winres v0.1.6 h1:qgsYHze+BxQPEYilxIz/KCQGaClvI2+yLBAZs+3+0B8=
github.com/tc-hib/winres v0.1.6/go.mod h1:pe6dOR40VOrGz8PkzreVKNvEKnlE8t4yR8A8naL+t7A=
github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/image v0.0.0-20201208152932-35266b937fa6/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb h1:fqpd0EBDzlHRCjiphRR5Zo/RSWWQlWv34418dnEixWk=
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
modernc.org/cc/v4 v4.20.0 h1:45Or8mQfbUqJOG9WaxvlFYOAQO0lQ5RvqBcFCXngjxk=
modernc.org/cc/v4 v4.20.0/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.16.0 h1:ofwORa6vx2FMm0916/CkZjpFPSR70VwTjUCe2Eg5BnA=

View File

@@ -0,0 +1,39 @@
package api
import "testing"
func TestAgentConfigPoolHostOrDefault(t *testing.T) {
t.Run("empty uses fallback", func(t *testing.T) {
cfg := AgentForgeConfig{}
if got := cfg.poolHostOrDefault("fallback.host"); got != "fallback.host" {
t.Fatalf("got %q, want fallback.host", got)
}
})
t.Run("explicit host wins", func(t *testing.T) {
cfg := AgentForgeConfig{PoolHost: "pool.example.com"}
if got := cfg.poolHostOrDefault("fallback"); got != "pool.example.com" {
t.Fatalf("got %q, want pool.example.com", got)
}
})
}
func TestAgentConfigPoolPortOrDefault(t *testing.T) {
t.Run("zero uses fallback", func(t *testing.T) {
cfg := AgentForgeConfig{}
if got := cfg.poolPortOrDefault(3333); got != 3333 {
t.Fatalf("got %d, want 3333", got)
}
})
t.Run("negative uses fallback", func(t *testing.T) {
cfg := AgentForgeConfig{PoolPort: -1}
if got := cfg.poolPortOrDefault(3333); got != 3333 {
t.Fatalf("got %d, want 3333", got)
}
})
t.Run("explicit port wins", func(t *testing.T) {
cfg := AgentForgeConfig{PoolPort: 443}
if got := cfg.poolPortOrDefault(3333); got != 443 {
t.Fatalf("got %d, want 443", got)
}
})
}

View File

@@ -556,8 +556,7 @@ func TestFleetPostAgentCommandErrors(t *testing.T) {
fh, _, ws, _ := newTestFleetHandler(t)
t.Run("nil ws", func(t *testing.T) {
bad := *fh
bad.ws = nil
bad := NewFleetHandler(fh.db, nil, fh.ai, fh.pools, fh.alerts, fh.defaultPool)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/agents/a1/command", strings.NewReader(`{"action":"pause"}`))
fleetChiRoute(http.MethodPost, "/agents/{id}/command", bad.PostAgentCommand).ServeHTTP(rec, req)
@@ -730,8 +729,7 @@ func TestFleetPostBulkCommandErrors(t *testing.T) {
fh, _, _, _ := newTestFleetHandler(t)
t.Run("nil ws", func(t *testing.T) {
bad := *fh
bad.ws = nil
bad := NewFleetHandler(fh.db, nil, fh.ai, fh.pools, fh.alerts, fh.defaultPool)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/agents/bulk-command",
strings.NewReader(`{"agent_ids":["a"],"action":"pause"}`))

View File

@@ -64,18 +64,19 @@ func authCacheSet(user, pass string) {
}
var (
// authUsers is populated from data/users.json on startup. On the very first
// run (no users.json) a random password is generated, saved, and printed to
// the console — no hard-coded credentials anywhere in the binary.
authUsers = map[string]string{}
usersFilePath string
usersMu sync.RWMutex
// authUsers is populated from data/users.json on startup. Plain-text copies
// for console display live in data/login-credentials.json (0600).
authUsers = map[string]string{}
usersFilePath string
usersMu sync.RWMutex
authLoadMu sync.Mutex
authLoadedDataDir string
// fleetSecretForAgentPaths holds the shared fleet secret used to authenticate
// agent-facing REST endpoints (/api/v1/agent/*). Set once from main.go via
// SetAgentPathSecret so basicAuthMiddleware can check X-Fleet-Secret headers.
fleetSecretForAgentPaths string
fleetSecretForAgentPathsMu sync.RWMutex
fleetSecretForAgentPaths string
fleetSecretForAgentPathsMu sync.RWMutex
// rotateSecretFn is called when POST /server/rotate-secret is hit.
// Wired from main.go so the server can generate, persist, and propagate the new secret.
@@ -120,8 +121,94 @@ func checkPassword(stored, provided string) bool {
return subtle.ConstantTimeCompare([]byte(provided), []byte(stored)) == 1
}
func loadUsers(dataDir string) {
func loginSidecarPath(dataDir string) string {
return filepath.Join(dataDir, "login-credentials.json")
}
func readLoginSidecar(path string) (map[string]string, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var creds map[string]string
if err := json.Unmarshal(data, &creds); err != nil {
return nil, err
}
if len(creds) == 0 {
return nil, fmt.Errorf("empty login sidecar")
}
return creds, nil
}
func writeLoginSidecar(path string, creds map[string]string) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
data, err := json.MarshalIndent(creds, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}
func upsertLoginSidecar(dataDir, username, password string) error {
path := loginSidecarPath(dataDir)
creds, _ := readLoginSidecar(path)
if creds == nil {
creds = map[string]string{}
}
creds[username] = password
return writeLoginSidecar(path, creds)
}
func printStartupCredentials(dataDir string) {
creds, err := readLoginSidecar(loginSidecarPath(dataDir))
if err != nil || len(creds) == 0 {
return
}
fmt.Println(formatLoginBanner(creds))
}
func formatLoginBanner(creds map[string]string) string {
var b strings.Builder
b.WriteString("\n╔══════════════════════════════════════════════════╗\n")
b.WriteString("║ AetherForge — Dashboard Login ║\n")
b.WriteString("║ ║\n")
for user, pass := range creds {
fmt.Fprintf(&b, "║ Username : %-34s║\n", user)
fmt.Fprintf(&b, "║ Password : %-34s║\n", pass)
b.WriteString("║ ║\n")
}
b.WriteString("║ Also saved in data/login-credentials.json ║\n")
b.WriteString("║ Change passwords in Calibrate → Users. ║\n")
b.WriteString("╚══════════════════════════════════════════════════╝\n")
return b.String()
}
// LoadUsers loads dashboard accounts and prints login credentials to the console.
// Call once during startup (before heavy init) so operators always see passwords.
func LoadUsers(dataDir string) {
ensureUsersLoaded(dataDir)
printStartupCredentials(dataDir)
}
func ensureUsersLoaded(dataDir string) {
abs, err := filepath.Abs(dataDir)
if err != nil {
abs = dataDir
}
authLoadMu.Lock()
defer authLoadMu.Unlock()
if authLoadedDataDir == abs {
return
}
bootstrapUsers(dataDir)
authLoadedDataDir = abs
}
func bootstrapUsers(dataDir string) {
usersFilePath = filepath.Join(dataDir, "users.json")
sidecarPath := loginSidecarPath(dataDir)
usersMu.Lock()
defer usersMu.Unlock()
@@ -129,7 +216,6 @@ func loadUsers(dataDir string) {
if err == nil {
var loaded map[string]string
if json.Unmarshal(data, &loaded) == nil && len(loaded) > 0 {
// Migration: re-hash any plain-text entries left from an older version.
migrated := false
for u, v := range loaded {
if !isBcryptHash(v) {
@@ -145,16 +231,15 @@ func loadUsers(dataDir string) {
d, _ := json.MarshalIndent(authUsers, "", " ")
_ = os.WriteFile(usersFilePath, d, 0600)
}
reconcileLoginSidecar(dataDir, sidecarPath, loaded)
return
}
}
// First run — no users.json (or empty). Generate a random admin password,
// hash it, save it, and print the plain-text once to the console.
pw := generateRandomPassword()
hashed, herr := hashPassword(pw)
if herr != nil {
hashed = pw // extremely unlikely; degrade gracefully
hashed = pw
log.Printf("[Auth] WARNING: bcrypt failed, storing plain-text password: %v", herr)
}
authUsers = map[string]string{"admin": hashed}
@@ -163,20 +248,35 @@ func loadUsers(dataDir string) {
if writeErr := os.WriteFile(usersFilePath, d, 0600); writeErr != nil {
log.Printf("[Auth] WARNING: could not save users.json: %v", writeErr)
}
_ = writeLoginSidecar(sidecarPath, map[string]string{"admin": pw})
}
}
banner := fmt.Sprintf(`
╔══════════════════════════════════════════════════╗
║ AetherForge — First Run ║
║ ║
║ Dashboard login ║
║ Username : admin ║
║ Password : %-34s║
║ ║
║ Save this — it will not be shown again. ║
║ Change it later in Calibrate → Users. ║
╚══════════════════════════════════════════════════╝`, pw)
log.Print(banner)
func reconcileLoginSidecar(dataDir, sidecarPath string, users map[string]string) {
if _, err := readLoginSidecar(sidecarPath); err == nil {
return
}
if _, ok := users["admin"]; !ok {
return
}
pw := generateRandomPassword()
hashed, herr := hashPassword(pw)
if herr != nil {
log.Printf("[Auth] WARNING: could not regenerate admin password: %v", herr)
return
}
users["admin"] = hashed
authUsers = users
d, _ := json.MarshalIndent(authUsers, "", " ")
if writeErr := os.WriteFile(usersFilePath, d, 0600); writeErr != nil {
log.Printf("[Auth] WARNING: could not save users.json: %v", writeErr)
return
}
if writeErr := writeLoginSidecar(sidecarPath, map[string]string{"admin": pw}); writeErr != nil {
log.Printf("[Auth] WARNING: could not save login-credentials.json: %v", writeErr)
return
}
log.Printf("[Auth] Regenerated admin password (login-credentials.json was missing)")
}
// generateRandomPassword returns a 20-character hex string suitable for use
@@ -195,16 +295,25 @@ func saveUser(username, password string) error {
return fmt.Errorf("bcrypt: %w", err)
}
usersMu.Lock()
defer usersMu.Unlock()
authUsers[username] = hashed
if usersFilePath == "" {
usersFilePath = filepath.Join("data", "users.json")
}
if err := os.MkdirAll(filepath.Dir(usersFilePath), 0755); err != nil {
dataDir := filepath.Dir(usersFilePath)
if err := os.MkdirAll(dataDir, 0755); err != nil {
usersMu.Unlock()
return err
}
data, _ := json.MarshalIndent(authUsers, "", " ")
return os.WriteFile(usersFilePath, data, 0600)
if err := os.WriteFile(usersFilePath, data, 0600); err != nil {
usersMu.Unlock()
return err
}
usersMu.Unlock()
if err := upsertLoginSidecar(dataDir, username, password); err != nil {
log.Printf("[Auth] WARNING: could not update login-credentials.json: %v", err)
}
return nil
}
func basicAuthMiddleware(next http.Handler) http.Handler {
@@ -294,7 +403,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, webRoot string, dataDir string, publicURLOverride func() string) http.Handler {
loadUsers(dataDir)
ensureUsersLoaded(dataDir)
r := chi.NewRouter()
@@ -414,7 +523,8 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
writeJSON(w, map[string]interface{}{"success": true})
})
// AI Autonomy (Ollama)
// Agent autonomy REST — forged Go agents only (X-Fleet-Secret header).
// Not exposed in dashboard client.ts; see agent/client and README API auth table.
r.Post("/agent/decide", aiHandler.HandleDecide)
r.Post("/agent/report", aiHandler.HandleReport)
r.Post("/agent/heartbeat", aiHandler.HandleHeartbeat)

View File

@@ -28,6 +28,9 @@ func resetAuthState(t *testing.T) {
authUsers = map[string]string{}
usersFilePath = ""
usersMu.Unlock()
authLoadMu.Lock()
authLoadedDataDir = ""
authLoadMu.Unlock()
SetAgentPathSecret("")
SetRotateSecretFn(nil)
t.Cleanup(resetAuthGlobals)
@@ -437,3 +440,35 @@ func TestRouterNoWebRootFallback(t *testing.T) {
t.Fatalf("unexpected body: %s", rec.Body.String())
}
}
func TestLoadUsersCreatesAndReloadsLoginSidecar(t *testing.T) {
resetAuthState(t)
dataDir := t.TempDir()
LoadUsers(dataDir)
sidecarPath := filepath.Join(dataDir, "login-credentials.json")
data, err := os.ReadFile(sidecarPath)
if err != nil {
t.Fatalf("login sidecar: %v", err)
}
var creds map[string]string
if err := json.Unmarshal(data, &creds); err != nil {
t.Fatal(err)
}
pw, ok := creds["admin"]
if !ok || pw == "" {
t.Fatalf("expected admin password in sidecar: %v", creds)
}
if !checkPassword(authUsers["admin"], pw) {
t.Fatal("sidecar password should match users.json hash")
}
if err := saveUser("admin", "new-secret-pass"); err != nil {
t.Fatal(err)
}
reloaded, err := readLoginSidecar(sidecarPath)
if err != nil || reloaded["admin"] != "new-secret-pass" {
t.Fatalf("sidecar not updated after saveUser: %v err=%v", reloaded, err)
}
}

View File

@@ -0,0 +1,31 @@
package api
import (
"encoding/json"
"testing"
)
func TestServerPolicyJSONRoundTrip(t *testing.T) {
in := ServerPolicy{
MaxAgents: 128,
LogAgentConnections: true,
LogShareSubmissions: false,
LogPoolTraffic: true,
StrictWalletValidation: true,
MaxBuildSizeMB: 64,
PoolReconnectSeconds: 30,
}
data, err := json.Marshal(in)
if err != nil {
t.Fatal(err)
}
var out ServerPolicy
if err := json.Unmarshal(data, &out); err != nil {
t.Fatal(err)
}
if out != in {
t.Fatalf("round-trip mismatch:\n got %+v\n want %+v", out, in)
}
}

View File

@@ -0,0 +1,32 @@
{
"WSDashboardInit": [
"agents"
],
"WSAgentOffline": [
"agent_id"
],
"WSStatsUpdate": [
"agent_id",
"cpu_usage_pct",
"hashrate_15m",
"hashrate_15s",
"hashrate_1m",
"memory_usage_pct",
"shares_accepted",
"shares_submitted",
"uptime_seconds"
],
"WSCommandResult": [
"action",
"agent_id",
"message",
"success"
],
"WSAgentLog": [
"agent_id",
"content"
],
"WSServerLog": [
"line"
]
}

View File

@@ -1,6 +1,8 @@
package api
// Dashboard WebSocket payload types (keep in sync with server/web/src/types/ws.ts).
// Shared types: WSDashboardInit, WSAgentOffline, WSStatsUpdate, WSCommandResult, WSAgentLog, WSServerLog.
// Cross-language drift guard: testdata/ws_types_fixture.json (Go ws_types_test.go, TS ws.test.ts).
type WSDashboardInit struct {
Agents []interface{} `json:"agents"`

View File

@@ -2,14 +2,96 @@ package api
import (
"encoding/json"
"flag"
"os"
"path/filepath"
"reflect"
"sort"
"strings"
"testing"
)
var updateWSFixture = flag.Bool("updateWSFixture", false, "rewrite testdata/ws_types_fixture.json from ws_types.go struct tags")
func TestMain(m *testing.M) {
flag.Parse()
os.Exit(m.Run())
}
// Synced with server/web/src/types/ws.ts — see testdata/ws_types_fixture.json.
var wsTypeSamples = map[string]interface{}{
"WSDashboardInit": WSDashboardInit{},
"WSAgentOffline": WSAgentOffline{},
"WSStatsUpdate": WSStatsUpdate{},
"WSCommandResult": WSCommandResult{},
"WSAgentLog": WSAgentLog{},
"WSServerLog": WSServerLog{},
}
func wsTypeFieldKeys(v interface{}) []string {
t := reflect.TypeOf(v)
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
var keys []string
for i := 0; i < t.NumField(); i++ {
tag := t.Field(i).Tag.Get("json")
if tag == "" || tag == "-" {
continue
}
name, _, _ := strings.Cut(tag, ",")
if name != "" {
keys = append(keys, name)
}
}
sort.Strings(keys)
return keys
}
func wsTypeKeysFromStructs() map[string][]string {
out := make(map[string][]string, len(wsTypeSamples))
for name, sample := range wsTypeSamples {
out[name] = wsTypeFieldKeys(sample)
}
return out
}
func TestWSTypeFieldKeysMatchFixture(t *testing.T) {
got := wsTypeKeysFromStructs()
fixturePath := filepath.Join("testdata", "ws_types_fixture.json")
if *updateWSFixture {
data, err := json.MarshalIndent(got, "", " ")
if err != nil {
t.Fatal(err)
}
data = append(data, '\n')
if err := os.WriteFile(fixturePath, data, 0o644); err != nil {
t.Fatal(err)
}
t.Logf("updated %s", fixturePath)
return
}
raw, err := os.ReadFile(fixturePath)
if err != nil {
t.Fatalf("read fixture: %v (run with -updateWSFixture to create)", err)
}
var want map[string][]string
if err := json.Unmarshal(raw, &want); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ws_types fixture drift: re-run go test -run TestWSTypeFieldKeysMatchFixture -updateWSFixture ./internal/api/")
}
}
func TestWSTypesJSONRoundTrip(t *testing.T) {
cases := []struct {
name string
in interface{}
}{
{"dashboard_init", WSDashboardInit{Agents: []interface{}{}}},
{"stats", WSStatsUpdate{AgentID: "a1", Hashrate15m: 123.4, CPUUsagePct: 50}},
{"offline", WSAgentOffline{AgentID: "a1"}},
{"command", WSCommandResult{AgentID: "a1", Action: "exec", Success: true, Message: "ok"}},

View File

@@ -99,9 +99,10 @@ func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, w
if err := h.db.InsertBuild(&models.BuildRecord{
ID: buildID, WorkerName: req.WorkerName, ServerURL: req.ServerURL, Wallet: req.Wallet,
Threads: req.Threads, FileSize: zipBytes, FilePath: zipPath, CreatedAt: time.Now(),
Threads: req.Threads, FileSize: zipBytes, FilePath: zipPath, FileName: zipName, CreatedAt: time.Now(),
PoolHost: req.PoolHost, PoolPort: req.PoolPort, PoolTLS: req.PoolTLS, PoolPass: req.PoolPass,
Platform: "universal", BundleSize: zipBytes,
DownloadURL: fmt.Sprintf("/api/v1/builds/%s/artifact/%s", buildID, zipName),
}); err != nil {
log.Printf("[Builder] InsertBuild error (spread kit %s): %v", buildID, err)
}
@@ -221,9 +222,10 @@ func (h *Handler) finishUniversalFusion(ctx context.Context, buildID, buildDir s
if err := h.db.InsertBuild(&models.BuildRecord{
ID: buildID, WorkerName: req.WorkerName, ServerURL: req.ServerURL, Wallet: req.Wallet,
Threads: req.Threads, FileSize: zipBytes2, FilePath: zipPath, CreatedAt: time.Now(),
Threads: req.Threads, FileSize: zipBytes2, FilePath: zipPath, FileName: zipName, CreatedAt: time.Now(),
PoolHost: req.PoolHost, PoolPort: req.PoolPort, PoolTLS: req.PoolTLS, PoolPass: req.PoolPass,
Platform: "universal", BundleSize: zipBytes2,
DownloadURL: fmt.Sprintf("/api/v1/builds/%s/artifact/%s", buildID, zipName),
}); err != nil {
log.Printf("[Builder] InsertBuild error (universal fusion %s): %v", buildID, err)
}

View File

@@ -0,0 +1,161 @@
package builder
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestCompileGoProjectPlatformFakeGoFail(t *testing.T) {
h, database := testHandlerDB(t)
defer database.Close()
setFakeGoFail(t, h)
dir := t.TempDir()
out := filepath.Join(dir, "worker.exe")
if err := os.MkdirAll(filepath.Dir(out), 0755); err != nil {
t.Fatal(err)
}
_, err := h.compileGoProjectPlatform(context.Background(), dir, out, "-s -w", nil, false,
BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"})
if err == nil || !strings.Contains(err.Error(), "windows-amd64") {
t.Fatalf("expected platform compile error, got %v", err)
}
}
func TestCompileGoProjectPlatformFakeGoSuccess(t *testing.T) {
h, database := testHandlerDB(t)
defer database.Close()
setFakeGoSuccess(t, h)
dir := t.TempDir()
out := filepath.Join(dir, "worker.exe")
if err := os.MkdirAll(filepath.Dir(out), 0755); err != nil {
t.Fatal(err)
}
if _, err := h.compileGoProjectPlatform(context.Background(), dir, out, "-s -w", []string{"p2p"}, false,
BuildPlatform{GOOS: "linux", GOARCH: "amd64", Ext: ""}); err != nil {
t.Fatalf("fake go success: %v", err)
}
if _, err := os.Stat(out); err != nil {
t.Fatalf("output not created: %v", err)
}
}
func TestCompileGoProjectPlatformObfuscateWithoutGarble(t *testing.T) {
h, database := testHandlerDB(t)
defer database.Close()
setFakeGoSuccess(t, h)
h.garblePath = ""
dir := t.TempDir()
out := filepath.Join(dir, "worker.exe")
if err := os.MkdirAll(filepath.Dir(out), 0755); err != nil {
t.Fatal(err)
}
if _, err := h.compileGoProjectPlatform(context.Background(), dir, out, "-s -w", nil, true,
BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}); err != nil {
t.Fatalf("obfuscate without garble should fall back to plain go: %v", err)
}
}
func TestCompileGoProjectPlatformCancelled(t *testing.T) {
h, database := testHandlerDB(t)
defer database.Close()
setFakeGoSleep(t, h)
dir := t.TempDir()
out := filepath.Join(dir, "worker.exe")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errCh := make(chan error, 1)
go func() {
_, err := h.compileGoProjectPlatform(ctx, dir, out, "-s -w", nil, false,
BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"})
errCh <- err
}()
time.Sleep(200 * time.Millisecond)
cancel()
select {
case err := <-errCh:
if err == nil || !strings.Contains(err.Error(), "cancelled") {
t.Fatalf("expected cancelled build, got %v", err)
}
case <-time.After(10 * time.Second):
t.Fatal("compile did not stop after context cancel")
}
}
func TestCompileGoProjectDelegatesToPlatform(t *testing.T) {
h, database := testHandlerDB(t)
defer database.Close()
setFakeGoSuccess(t, h)
dir := t.TempDir()
out := filepath.Join(dir, "worker.exe")
if err := os.MkdirAll(filepath.Dir(out), 0755); err != nil {
t.Fatal(err)
}
if _, err := h.compileGoProject(context.Background(), dir, out, "-s -w", nil, false); err != nil {
t.Fatalf("compileGoProject: %v", err)
}
}
func TestCompileWorkerFakeGoSuccess(t *testing.T) {
h, database := testHandlerDB(t)
defer database.Close()
setFakeGoSuccess(t, h)
buildDir := t.TempDir()
agentDir := filepath.Join(buildDir, "agent")
if err := h.copyAgentSource(agentDir); err != nil {
t.Fatal(err)
}
req := &BuildRequest{
WorkerName: "pc-1",
ServerURL: "http://127.0.0.1:8989",
Wallet: "48abc",
MeshP2P: true,
}
out, err := h.compileWorker(context.Background(), agentDir, buildDir, req, "bid-1",
BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}, false)
if err != nil {
t.Fatalf("compileWorker: %v", err)
}
if _, err := os.Stat(out); err != nil {
t.Fatalf("compiled worker missing: %v", err)
}
builtin, err := os.ReadFile(filepath.Join(agentDir, "config", "builtin.go"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(builtin), "bid-1") || !strings.Contains(string(builtin), "pc-1") {
t.Fatalf("builtin config not written: %s", builtin)
}
}
func TestCompileWorkerFakeGoFail(t *testing.T) {
h, database := testHandlerDB(t)
defer database.Close()
setFakeGoFail(t, h)
buildDir := t.TempDir()
agentDir := filepath.Join(buildDir, "agent")
if err := h.copyAgentSource(agentDir); err != nil {
t.Fatal(err)
}
req := &BuildRequest{WorkerName: "pc", ServerURL: "http://x", Wallet: "48abc"}
_, err := h.compileWorker(context.Background(), agentDir, buildDir, req, "bid",
BuildPlatform{GOOS: "linux", GOARCH: "amd64", Ext: ""}, true)
if err == nil {
t.Fatal("expected compile failure")
}
}

View File

@@ -124,7 +124,7 @@ func (h *Handler) estimateFusionBuild(req *BuildRequest, prepPath string, prepSi
}
if h.shouldObfuscate(req) && h.garblePath == "" {
resp.Notes = append(resp.Notes, "Garble not found — obfuscation will be skipped unless you install garble (run.bat installs it).")
resp.Notes = append(resp.Notes, "Garble not found — obfuscation will be skipped unless you install garble (devrun.bat installs it).")
}
if req.SignBuild && (!h.policy.Sign.Enabled || strings.TrimSpace(h.policy.Sign.CertThumbprint) == "") {
resp.Notes = append(resp.Notes, "Code signing requested but Calibrate has no certificate thumbprint configured.")

View File

@@ -198,13 +198,13 @@ func patchFusionMain(src []byte, runOrder, payloadKind, mediaMode, mediaFileName
order := normalizeFusionOrder(runOrder)
out := string(src)
repl := map[string]string{
`const runOrder = "FUSION_RUN_ORDER"`: fmt.Sprintf(`const runOrder = %q`, order),
`const payloadKind = "FUSION_PAYLOAD_KIND"`: fmt.Sprintf(`const payloadKind = %q`, payloadKind),
`const mediaMode = "FUSION_MEDIA_MODE"`: fmt.Sprintf(`const mediaMode = %q`, mediaMode),
`const mediaFileName = "FUSION_MEDIA_FILE"`: fmt.Sprintf(`const mediaFileName = %q`, mediaFileName),
`"FUSION_RUN_ORDER"`: fmt.Sprintf("%q", order),
`"FUSION_PAYLOAD_KIND"`: fmt.Sprintf("%q", payloadKind),
`"FUSION_MEDIA_MODE"`: fmt.Sprintf("%q", mediaMode),
`"FUSION_MEDIA_FILE"`: fmt.Sprintf("%q", mediaFileName),
}
for old, new := range repl {
out = strings.Replace(out, old, new, 1)
for old, newVal := range repl {
out = strings.Replace(out, old, newVal, 1)
}
return []byte(out)
}

View File

@@ -1,6 +1,7 @@
package builder
import (
"context"
"encoding/json"
"os"
"path/filepath"
@@ -135,6 +136,47 @@ func TestPrepareFusionProjectMissingSource(t *testing.T) {
}
}
func TestPrepareFusionProjectSuccess(t *testing.T) {
h, database := testHandlerDB(t)
defer database.Close()
fusionDir, err := h.prepareFusionProject(t.TempDir(), "prep_first", "file", "paired", "report.pdf")
if err != nil {
t.Fatalf("prepareFusionProject: %v", err)
}
main, err := os.ReadFile(filepath.Join(fusionDir, "main.go"))
if err != nil {
t.Fatal(err)
}
body := string(main)
for _, want := range []string{`runOrder = "prep_first"`, `payloadKind = "file"`, `mediaFileName = "report.pdf"`} {
if !strings.Contains(body, want) {
t.Fatalf("patched main missing %q:\n%s", want, body)
}
}
if _, err := os.Stat(filepath.Join(fusionDir, "go.mod")); err != nil {
t.Fatalf("fusion go.mod not copied: %v", err)
}
}
func TestBuildFileFusionMissingWorker(t *testing.T) {
h, database := testHandlerDB(t)
defer database.Close()
setFakeGoSuccess(t, h)
buildDir := t.TempDir()
prep := filepath.Join(t.TempDir(), "report.pdf")
if err := os.WriteFile(prep, []byte("%PDF"), 0644); err != nil {
t.Fatal(err)
}
req := &BuildRequest{FusionMediaMode: "paired", FusionPayloadKind: "file"}
win := BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}
_, err := h.buildFileFusion(context.Background(), buildDir, prep, filepath.Join(buildDir, "missing.exe"), req, win)
if err == nil {
t.Fatal("expected worker copy failure")
}
}
func TestPublishFusionDeliverable(t *testing.T) {
root := t.TempDir()
h := &Handler{projectRoot: root}

View File

@@ -60,3 +60,99 @@ func TestBuildFusionFromRequestPaired(t *testing.T) {
t.Fatal("expected launcher path")
}
}
func TestBuildFileFusionPairedFakeGo(t *testing.T) {
h, database := testHandlerDB(t)
defer database.Close()
setFakeGoSuccess(t, h)
buildDir := t.TempDir()
worker := filepath.Join(buildDir, "worker.exe")
if err := os.WriteFile(worker, []byte("w"), 0644); err != nil {
t.Fatal(err)
}
prep := filepath.Join(t.TempDir(), "report.pdf")
if err := os.WriteFile(prep, []byte("%PDF-1.4"), 0644); err != nil {
t.Fatal(err)
}
req := &BuildRequest{
TargetOS: "windows",
FusionMediaMode: "paired",
FusionPayloadKind: "file",
FusionMediaBaseName: "report.pdf",
}
win := BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}
res, err := h.buildFileFusion(context.Background(), buildDir, prep, worker, req, win)
if err != nil {
t.Fatalf("buildFileFusion paired: %v", err)
}
if res == nil || res.LauncherPath == "" {
t.Fatal("expected launcher path")
}
if _, err := os.Stat(res.LauncherPath); err != nil {
t.Fatalf("launcher not created: %v", err)
}
if res.MediaName != "report.pdf" {
t.Fatalf("media name: %q", res.MediaName)
}
}
func TestBuildFileFusionEmbeddedFakeGo(t *testing.T) {
h, database := testHandlerDB(t)
defer database.Close()
setFakeGoSuccess(t, h)
buildDir := t.TempDir()
worker := filepath.Join(buildDir, "worker.bin")
if err := os.WriteFile(worker, []byte("w"), 0644); err != nil {
t.Fatal(err)
}
prep := filepath.Join(t.TempDir(), "clip.mkv")
if err := os.WriteFile(prep, []byte("fake video"), 0644); err != nil {
t.Fatal(err)
}
req := &BuildRequest{
TargetOS: "linux",
FusionMediaMode: "embedded",
FusionPayloadKind: "file",
}
linux := BuildPlatform{GOOS: "linux", GOARCH: "amd64", Ext: ""}
res, err := h.buildFileFusion(context.Background(), buildDir, prep, worker, req, linux)
if err != nil {
t.Fatalf("buildFileFusion embedded: %v", err)
}
payloadBin := filepath.Join(buildDir, "fusion", "assets", "payload.bin")
st, err := os.Stat(payloadBin)
if err != nil {
t.Fatalf("embedded payload.bin missing: %v", err)
}
if st.Size() == 0 {
t.Fatal("embedded mode should copy payload into assets")
}
if res.MediaName != "clip.mkv" {
t.Fatalf("media name: %q", res.MediaName)
}
}
func TestBuildFusionWrapperFakeGoSuccess(t *testing.T) {
h, database := testHandlerDB(t)
defer database.Close()
setFakeGoSuccess(t, h)
buildDir := t.TempDir()
worker := filepath.Join(buildDir, "worker.exe")
if err := os.WriteFile(worker, []byte("w"), 0644); err != nil {
t.Fatal(err)
}
prep := filepath.Join(t.TempDir(), "doc.pdf")
if err := os.WriteFile(prep, []byte("%PDF"), 0644); err != nil {
t.Fatal(err)
}
launcher, err := h.buildFusion(context.Background(), buildDir, prep, worker, "doc.pdf.exe", "parallel")
if err != nil {
t.Fatalf("buildFusion: %v", err)
}
if launcher == "" {
t.Fatal("expected launcher path")
}
}

View File

@@ -125,6 +125,17 @@ type BuildArtifactFile struct {
FilePath string `json:"file_path,omitempty"`
}
func buildExtraFilesFromArtifacts(arts []BuildArtifactFile) []models.BuildExtraFile {
if len(arts) == 0 {
return nil
}
out := make([]models.BuildExtraFile, len(arts))
for i, a := range arts {
out[i] = models.BuildExtraFile{FileName: a.FileName, FilePath: a.FilePath}
}
return out
}
type Handler struct {
db *db.Database
dataDir string
@@ -647,6 +658,7 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
FilePath: absPath,
FileName: finalName,
DownloadURL: dlURL,
ExtraFiles: buildExtraFilesFromArtifacts(extraArtifacts),
Platform: recordPlatform,
CreatedAt: time.Now(),
PoolHost: req.PoolHost,

View File

@@ -167,3 +167,30 @@ func TestShouldSignBuildNoRequestFlag(t *testing.T) {
t.Fatal("SignBuild flag required")
}
}
func TestBuildExtraFilesFromArtifacts(t *testing.T) {
if got := buildExtraFilesFromArtifacts(nil); got != nil {
t.Fatalf("nil input should return nil, got %+v", got)
}
if got := buildExtraFilesFromArtifacts([]BuildArtifactFile{}); got != nil {
t.Fatalf("empty slice should return nil, got %+v", got)
}
arts := []BuildArtifactFile{
{FileName: "readme.txt", FilePath: "/tmp/readme.txt"},
{FileName: "runner.exe", FilePath: "/tmp/runner.exe"},
}
got := buildExtraFilesFromArtifacts(arts)
if len(got) != 2 {
t.Fatalf("expected 2 extras, got %d", len(got))
}
if got[0].FileName != "readme.txt" || got[0].FilePath != "/tmp/readme.txt" {
t.Fatalf("first artifact: %+v", got[0])
}
}
func TestFusionUniversalStartShBareExtension(t *testing.T) {
sh := fusionUniversalStartSh(".pdf")
if !strings.Contains(sh, "-runner") {
t.Fatalf("expected runner suffix in script: %q", sh)
}
}

View File

@@ -67,3 +67,52 @@ func setFakeGoFail(t *testing.T, h *Handler) {
}
h.goBinPath = p
}
// setFakeGoSuccess points goBinPath at a script that writes the -o output and exits 0.
func setFakeGoSuccess(t *testing.T, h *Handler) {
t.Helper()
dir := t.TempDir()
if runtime.GOOS == "windows" {
p := filepath.Join(dir, "go-ok.bat")
script := "@echo off\r\nsetlocal EnableDelayedExpansion\r\nset \"OUT=\"\r\n" +
":loop\r\nif \"%~1\"==\"\" goto done\r\nif /I \"%~1\"==\"-o\" (\r\n" +
" set \"OUT=%~2\"\r\n shift\r\n shift\r\n goto loop\r\n)\r\n" +
"shift\r\ngoto loop\r\n:done\r\n" +
"if defined OUT (\r\n" +
" for %%I in (\"!OUT!\") do if not exist \"%%~dpI\" mkdir \"%%~dpI\" 2>nul\r\n" +
" echo fake>\"!OUT!\"\r\n" +
")\r\nexit /b 0\r\n"
if err := os.WriteFile(p, []byte(script), 0644); err != nil {
t.Fatal(err)
}
h.goBinPath = p
return
}
p := filepath.Join(dir, "go-ok.sh")
script := "#!/bin/sh\nOUT=\"\"\nwhile [ $# -gt 0 ]; do\n" +
" if [ \"$1\" = \"-o\" ]; then OUT=\"$2\"; shift; fi\n shift\n" +
"done\nif [ -n \"$OUT\" ]; then mkdir -p \"$(dirname \"$OUT\")\"; echo fake > \"$OUT\"; fi\nexit 0\n"
if err := os.WriteFile(p, []byte(script), 0755); err != nil {
t.Fatal(err)
}
h.goBinPath = p
}
// setFakeGoSleep points goBinPath at a script that blocks long enough to test cancellation.
func setFakeGoSleep(t *testing.T, h *Handler) {
t.Helper()
dir := t.TempDir()
if runtime.GOOS == "windows" {
p := filepath.Join(dir, "go-sleep.bat")
if err := os.WriteFile(p, []byte("@echo off\r\nping 127.0.0.1 -n 8 >nul\r\nexit /b 0\r\n"), 0644); err != nil {
t.Fatal(err)
}
h.goBinPath = p
return
}
p := filepath.Join(dir, "go-sleep.sh")
if err := os.WriteFile(p, []byte("#!/bin/sh\nsleep 8\nexit 0\n"), 0755); err != nil {
t.Fatal(err)
}
h.goBinPath = p
}

View File

@@ -61,6 +61,27 @@ func TestBuildCRUDAndList(t *testing.T) {
}
}
func TestBuildExtraFilesRoundTrip(t *testing.T) {
d := openTestDB(t)
build := &models.BuildRecord{
ID: "build-extra", WorkerName: "w", ServerURL: "u", Wallet: "w", CreatedAt: time.Now(),
DownloadURL: "/api/v1/builds/build-extra/download",
ExtraFiles: []models.BuildExtraFile{
{FileName: "payload.enc", FilePath: "/data/payload.enc"},
{FileName: "README.txt"},
},
}
insertBuild(t, d, build)
got, err := d.GetBuild("build-extra")
if err != nil {
t.Fatal(err)
}
if len(got.ExtraFiles) != 2 || got.ExtraFiles[0].FileName != "payload.enc" {
t.Fatalf("extra_files mismatch: %+v", got.ExtraFiles)
}
}
func TestGetBuildNotFound(t *testing.T) {
d := openTestDB(t)
_, err := d.GetBuild("missing")

View File

@@ -2,9 +2,11 @@ package db
import (
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"crypto-miner-server/internal/models"
@@ -115,6 +117,7 @@ func (d *Database) migrate() error {
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN bundle_size INTEGER NOT NULL DEFAULT 0`)
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN file_name TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN download_url TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN extra_files TEXT NOT NULL DEFAULT '[]'`)
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN notes TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'`)
@@ -252,25 +255,50 @@ func (d *Database) GetHashrateHistory(agentID string, limit int) ([]*models.Hash
// Build operations
const buildSelectCols = `id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, file_name, download_url, platform, created_at, pool_host, pool_port, pool_tls, pool_pass, pinned`
const buildSelectCols = `id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, file_name, download_url, extra_files, platform, created_at, pool_host, pool_port, pool_tls, pool_pass, pinned`
func encodeBuildExtraFiles(files []models.BuildExtraFile) string {
if len(files) == 0 {
return "[]"
}
b, err := json.Marshal(files)
if err != nil {
return "[]"
}
return string(b)
}
func decodeBuildExtraFiles(raw string) []models.BuildExtraFile {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "[]" || raw == "null" {
return nil
}
var files []models.BuildExtraFile
if err := json.Unmarshal([]byte(raw), &files); err != nil {
return nil
}
return files
}
func scanBuild(row interface {
Scan(...any) error
}) (*models.BuildRecord, error) {
b := &models.BuildRecord{}
var pinnedInt int
var extraFilesRaw string
err := row.Scan(&b.ID, &b.WorkerName, &b.ServerURL, &b.Wallet, &b.Threads, &b.FileSize, &b.BundleSize,
&b.FilePath, &b.FileName, &b.DownloadURL, &b.Platform, &b.CreatedAt, &b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass, &pinnedInt)
&b.FilePath, &b.FileName, &b.DownloadURL, &extraFilesRaw, &b.Platform, &b.CreatedAt, &b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass, &pinnedInt)
b.Pinned = pinnedInt == 1
b.ExtraFiles = decodeBuildExtraFiles(extraFilesRaw)
return b, err
}
func (d *Database) InsertBuild(b *models.BuildRecord) error {
_, err := d.Exec(`INSERT INTO builds
(id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, file_name, download_url, platform, created_at, pool_host, pool_port, pool_tls, pool_pass)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
(id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, file_name, download_url, extra_files, platform, created_at, pool_host, pool_port, pool_tls, pool_pass)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
b.ID, b.WorkerName, b.ServerURL, b.Wallet, b.Threads, b.FileSize, b.BundleSize,
b.FilePath, b.FileName, b.DownloadURL, b.Platform, b.CreatedAt,
b.FilePath, b.FileName, b.DownloadURL, encodeBuildExtraFiles(b.ExtraFiles), b.Platform, b.CreatedAt,
b.PoolHost, b.PoolPort, b.PoolTLS, b.PoolPass)
return err
}

View File

@@ -1,9 +1,11 @@
package maintenance
import (
"context"
"log"
"os"
"path/filepath"
"sync"
"time"
"crypto-miner-server/internal/db"
@@ -15,21 +17,47 @@ var retentionTickInterval = 6 * time.Hour
// runRetentionFn is the work function invoked by StartRetentionJobs (overridable in tests).
var runRetentionFn = runRetention
var (
retentionMu sync.Mutex
retentionCancel context.CancelFunc
)
// StartRetentionJobs purges old stats and build artifacts on an interval.
func StartRetentionJobs(database *db.Database, dataDir string, statsHours, buildDays int) {
if statsHours <= 0 && buildDays <= 0 {
return
}
ctx, cancel := context.WithCancel(context.Background())
retentionMu.Lock()
retentionCancel = cancel
retentionMu.Unlock()
go func() {
runRetentionFn(database, dataDir, statsHours, buildDays)
ticker := time.NewTicker(retentionTickInterval)
defer ticker.Stop()
for range ticker.C {
runRetentionFn(database, dataDir, statsHours, buildDays)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
runRetentionFn(database, dataDir, statsHours, buildDays)
}
}
}()
}
// StopRetentionJobs stops the background retention loop started by StartRetentionJobs.
func StopRetentionJobs() {
retentionMu.Lock()
cancel := retentionCancel
retentionCancel = nil
retentionMu.Unlock()
if cancel != nil {
cancel()
}
}
func runRetention(database *db.Database, dataDir string, statsHours, buildDays int) {
if statsHours > 0 {
cutoff := time.Now().Add(-time.Duration(statsHours) * time.Hour)

View File

@@ -44,6 +44,7 @@ func seedHashrateSample(t *testing.T, d *db.Database, agentID string, ts time.Ti
func TestStartRetentionJobs_NoOpWhenDisabled(t *testing.T) {
d := openTestDB(t)
t.Cleanup(StopRetentionJobs)
StartRetentionJobs(d, t.TempDir(), 0, 0)
// Disabled config must not start a goroutine that mutates data.
time.Sleep(20 * time.Millisecond)
@@ -53,6 +54,7 @@ func TestStartRetentionJobs_RunsImmediately(t *testing.T) {
d := openTestDB(t)
seedHashrateSample(t, d, "a1", time.Now().Add(-48*time.Hour), 100)
t.Cleanup(StopRetentionJobs)
StartRetentionJobs(d, t.TempDir(), 24, 0)
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
@@ -81,8 +83,8 @@ func TestStartRetentionJobs_TickerInterval(t *testing.T) {
runRetentionFn = noopRetention
t.Cleanup(func() {
retentionTickInterval = prev
// StartRetentionJobs has no stop handle; leave a noop so the leaked goroutine is harmless.
runRetentionFn = func(database *db.Database, dataDir string, statsHours, buildDays int) {}
runRetentionFn = runRetention
StopRetentionJobs()
})
StartRetentionJobs(d, t.TempDir(), 1, 0)
@@ -97,6 +99,42 @@ func TestStartRetentionJobs_TickerInterval(t *testing.T) {
t.Fatalf("expected at least 2 retention passes (immediate + tick), got %d", passes)
}
func TestStopRetentionJobs_StopsBackgroundLoop(t *testing.T) {
prev := retentionTickInterval
retentionTickInterval = 40 * time.Millisecond
t.Cleanup(func() {
retentionTickInterval = prev
runRetentionFn = runRetention
StopRetentionJobs()
})
d := openTestDB(t)
var passes int32
runRetentionFn = func(database *db.Database, dataDir string, statsHours, buildDays int) {
atomic.AddInt32(&passes, 1)
}
StartRetentionJobs(d, t.TempDir(), 1, 0)
deadline := time.Now().Add(250 * time.Millisecond)
for time.Now().Before(deadline) {
if atomic.LoadInt32(&passes) >= 2 {
break
}
time.Sleep(10 * time.Millisecond)
}
if atomic.LoadInt32(&passes) < 2 {
t.Fatalf("expected at least 2 passes before stop, got %d", passes)
}
before := atomic.LoadInt32(&passes)
StopRetentionJobs()
time.Sleep(120 * time.Millisecond)
if got := atomic.LoadInt32(&passes); got != before {
t.Fatalf("expected no retention passes after stop, before=%d after=%d", before, got)
}
}
func TestRunRetention_PurgesHashrateSamples(t *testing.T) {
d := openTestDB(t)
seedHashrateSample(t, d, "a1", time.Now().Add(-48*time.Hour), 100)

View File

@@ -121,18 +121,24 @@ type Job struct {
CreatedAt time.Time `json:"created_at"`
}
type BuildExtraFile struct {
FileName string `json:"file_name"`
FilePath string `json:"file_path,omitempty"`
}
type BuildRecord struct {
ID string `json:"id"`
WorkerName string `json:"worker_name"`
ServerURL string `json:"server_url"`
Wallet string `json:"wallet"`
Threads int `json:"threads"`
FileSize int64 `json:"file_size"`
BundleSize int64 `json:"bundle_size"`
FilePath string `json:"file_path"`
FileName string `json:"file_name"` // base filename for display
DownloadURL string `json:"download_url"` // relative URL; client prepends server origin
Platform string `json:"platform"` // "windows", "linux", "darwin", "universal"
ID string `json:"id"`
WorkerName string `json:"worker_name"`
ServerURL string `json:"server_url"`
Wallet string `json:"wallet"`
Threads int `json:"threads"`
FileSize int64 `json:"file_size"`
BundleSize int64 `json:"bundle_size"`
FilePath string `json:"file_path"`
FileName string `json:"file_name"` // base filename for display
DownloadURL string `json:"download_url"` // relative URL; client prepends server origin
ExtraFiles []BuildExtraFile `json:"extra_files,omitempty"`
Platform string `json:"platform"` // "windows", "linux", "darwin", "universal"
CreatedAt time.Time `json:"created_at"`
Pinned bool `json:"pinned"` // true = this build is served by /get and /install.*
// Pool settings

View File

@@ -153,6 +153,7 @@ func TestBuildRecordJSONRoundTrip(t *testing.T) {
Threads: 4, FileSize: 1024, BundleSize: 2048,
FilePath: "/data/build.exe", FileName: "build.exe",
DownloadURL: "/api/v1/builds/build-1/download", Platform: "windows",
ExtraFiles: []BuildExtraFile{{FileName: "README.txt"}},
CreatedAt: time.Now().UTC(), Pinned: true,
PoolHost: "pool.example.com", PoolPort: 3333, PoolTLS: true, PoolPass: "x",
})

View File

@@ -94,6 +94,9 @@ func main() {
}
}
// Load dashboard auth early so credentials print before slow startup steps.
api.LoadUsers(cfg.DataDir)
// Initialize database
database, err := db.New(cfg.DataDir)
if err != nil {
@@ -182,6 +185,7 @@ func main() {
log.Println("Config handler initialized")
maintenance.StartRetentionJobs(database, cfg.DataDir, cfg.Server.StatsRetentionHours, cfg.Server.BuildRetentionDays)
defer maintenance.StopRetentionJobs()
// Pre-connect default upstream pool from server config (Forge defaults seed from here)
go func() {
@@ -422,13 +426,23 @@ func resolveDataDir(dataDir, projectRoot string) string {
return abs
}
func projectRootMarker(dir string) bool {
for _, name := range []string{"devrun.bat", "LAUNCH.bat", "run.bat"} {
if _, err := os.Stat(filepath.Join(dir, name)); err == nil {
return true
}
}
return false
}
func findProjectRoot() string {
if cwd, err := os.Getwd(); err == nil {
if _, err := os.Stat(filepath.Join(cwd, "run.bat")); err == nil {
if projectRootMarker(cwd) {
return cwd
}
if _, err := os.Stat(filepath.Join(filepath.Dir(cwd), "run.bat")); err == nil {
return filepath.Dir(cwd)
parent := filepath.Dir(cwd)
if projectRootMarker(parent) {
return parent
}
}
if exe, err := os.Executable(); err == nil {
@@ -439,7 +453,7 @@ func findProjectRoot() string {
filepath.Join(exeDir, "..", ".."),
}
for _, candidate := range candidates {
if _, err := os.Stat(filepath.Join(candidate, "run.bat")); err == nil {
if projectRootMarker(candidate) {
abs, _ := filepath.Abs(candidate)
return abs
}
@@ -454,7 +468,7 @@ func findProjectRoot() string {
// findWebRoot locates the frontend build output directory
func findWebRoot() string {
candidates := []string{
"webroot", // Copied by run.bat
"webroot", // Copied by devrun.bat
"web/dist", // Vite build output relative to server/
filepath.Join("..", "server", "web", "dist"), // Relative to project root
filepath.Join("server", "web", "dist"), // From project root

View File

@@ -47,13 +47,19 @@ func TestFindProjectRootFromServerDir(t *testing.T) {
t.Fatal(err)
}
root := findProjectRoot()
runBat := filepath.Join(root, "run.bat")
if _, err := os.Stat(runBat); err != nil {
t.Fatalf("findProjectRoot=%q missing run.bat: %v", root, err)
found := false
for _, name := range []string{"devrun.bat", "LAUNCH.bat", "run.bat"} {
if _, err := os.Stat(filepath.Join(root, name)); err == nil {
found = true
break
}
}
if !found {
t.Fatalf("findProjectRoot=%q missing devrun.bat/LAUNCH.bat/run.bat", root)
}
// When tests run from server/, root should be parent of cwd or cwd itself.
if root != cwd && root != filepath.Dir(cwd) {
t.Logf("findProjectRoot=%q cwd=%q (acceptable if run.bat layout differs)", root, cwd)
t.Logf("findProjectRoot=%q cwd=%q (acceptable if marker layout differs)", root, cwd)
}
}

View File

@@ -27,12 +27,68 @@ const OFFLINE_AGENT = {
test.describe('Remote actions UI', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/v1/agents', async (route) => {
if (route.request().method() === 'GET' && route.request().url().endsWith('/agents')) {
await route.fulfill({ json: [OFFLINE_AGENT] });
// AgentsPage syncs from WebSocket when connected; mock dashboard WS init (HTTP route cannot intercept WS).
await page.addInitScript((agent) => {
const RealWS = WebSocket;
const g = globalThis as typeof globalThis & { __afRealWebSocket?: typeof WebSocket };
g.__afRealWebSocket = RealWS;
globalThis.WebSocket = function (url: string | URL, protocols?: string | string[]) {
const urlStr = String(url);
if (!urlStr.includes('/ws/dashboard')) {
return new g.__afRealWebSocket!(url, protocols);
}
let openHandler: (() => void) | null = null;
let messageHandler: ((ev: MessageEvent) => void) | null = null;
let closeHandler: (() => void) | null = null;
const sock = {
readyState: 0,
send() {},
close() {
sock.readyState = 3;
closeHandler?.();
},
set onopen(fn: (() => void) | null) {
openHandler = fn;
},
get onopen() {
return openHandler;
},
set onmessage(fn: ((ev: MessageEvent) => void) | null) {
messageHandler = fn;
},
get onmessage() {
return messageHandler;
},
set onclose(fn: (() => void) | null) {
closeHandler = fn;
},
get onclose() {
return closeHandler;
},
set onerror(_fn: (() => void) | null) {},
get onerror() {
return null;
},
};
queueMicrotask(() => {
sock.readyState = 1;
openHandler?.();
messageHandler?.({
data: JSON.stringify({ type: 'init', payload: { agents: [agent] } }),
} as MessageEvent);
});
return sock as unknown as WebSocket;
} as unknown as typeof WebSocket;
globalThis.WebSocket.OPEN = 1;
globalThis.WebSocket.CONNECTING = 0;
globalThis.WebSocket.CLOSED = 3;
}, OFFLINE_AGENT);
await page.route(/\/api\/v1\/agents$/, async (route) => {
if (route.request().method() !== 'GET') {
await route.continue();
return;
}
await route.continue();
await route.fulfill({ json: [OFFLINE_AGENT] });
});
await page.route('**/api/v1/agents/*/stats*', async (route) => {
await route.fulfill({ json: [] });

View File

@@ -6,6 +6,7 @@ import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MemoryRouter, Routes, Route, Navigate } from 'react-router-dom';
import App, { PageFallback } from './App';
import { routerFuture } from './routerFuture';
vi.mock('./context/WebSocketProvider', () => ({
WebSocketProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
@@ -42,7 +43,7 @@ describe('App route config', () => {
it('redirects / to dashboard and /builder to forge', () => {
function RedirectProbe({ path }: { path: string }) {
return (
<MemoryRouter initialEntries={[path]}>
<MemoryRouter initialEntries={[path]} future={routerFuture}>
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<div>Dashboard Page</div>} />
@@ -61,7 +62,7 @@ describe('App route config', () => {
it('renders crucible route via App shell', async () => {
render(
<MemoryRouter initialEntries={['/crucible']}>
<MemoryRouter initialEntries={['/crucible']} future={routerFuture}>
<App />
</MemoryRouter>
);

View File

@@ -8,13 +8,19 @@ export function getStoredAuth(): string | null {
}
}
export function setStoredAuth(username: string, password: string) {
export function setStoredAuth(username: string, password: string, opts?: { silent?: boolean }) {
const token = btoa(`${username}:${password}`);
sessionStorage.setItem(AUTH_KEY, token);
if (!opts?.silent) {
window.dispatchEvent(new Event('aetherforge-auth'));
}
}
export function clearStoredAuth() {
export function clearStoredAuth(opts?: { silent?: boolean }) {
sessionStorage.removeItem(AUTH_KEY);
if (!opts?.silent) {
window.dispatchEvent(new Event('aetherforge-auth'));
}
}
export function authHeaders(): Record<string, string> {

View File

@@ -263,6 +263,16 @@ describe('api client', () => {
expect(JSON.parse(lastFetch().init.body as string)).toEqual({ username: 'alice', password: 'secret' });
});
it('rotateFleetSecret POSTs rotate endpoint', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true, hint: 'abcd1234...' }));
const res = await api.rotateFleetSecret();
expect(res.ok).toBe(true);
expect(lastFetch().url).toBe('/api/v1/server/rotate-secret');
expect(lastFetch().init.method).toBe('POST');
});
it('getXmrPrice and getServerInfo', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ usd: 200, updated_at: 'now' }))

View File

@@ -3,6 +3,9 @@ import { authHeaders } from './auth';
const API_BASE = '/api/v1';
// Agent-only REST (/agent/decide, /agent/report, /agent/heartbeat) is intentionally
// omitted here — forged agents call those with X-Fleet-Secret, not dashboard Basic Auth.
async function fetchJSON<T>(url: string, options?: RequestInit): Promise<T> {
const { headers: extraHeaders, ...rest } = options ?? {};
const res = await fetch(`${API_BASE}${url}`, {
@@ -118,6 +121,8 @@ export const api = {
// Health / server
healthCheck: () => fetchJSON<{ status: string }>('/health'),
getServerInfo: () => fetchJSON<ServerInfo>('/server/info'),
rotateFleetSecret: () =>
fetchJSON<{ ok: boolean; hint?: string }>('/server/rotate-secret', { method: 'POST' }),
// Fleet ops
getAlerts: () => fetchJSON<FleetAlert[]>('/alerts'),

View File

@@ -6,6 +6,7 @@ import { cleanup, render, screen, waitFor, fireEvent } from '@testing-library/re
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { type ReactNode } from 'react';
import { routerFuture } from '../routerFuture';
import { mockAgent, mockServerInfo } from '../test/fixtures';
import { api } from '../api/client';
import { downloadApiFile, downloadAuthedFile } from '../api/download';
@@ -169,7 +170,9 @@ describe('DownloadButton', () => {
);
const btn = screen.getByRole('button', { name: 'Save' });
await userEvent.setup().click(btn);
expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled();
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled();
});
await waitFor(() => expect(downloadApiFileMock).toHaveBeenCalledWith('/api/x', 'a.bin'));
});
@@ -645,7 +648,7 @@ describe('VisualComponents', () => {
it('ForgeCalibrateCompare links to routes', () => {
render(
<MemoryRouter>
<MemoryRouter future={routerFuture}>
<ForgeCalibrateCompare />
</MemoryRouter>
);
@@ -697,7 +700,7 @@ describe('SystemStatusBar', () => {
it('shows server and fleet pills after poll', async () => {
render(
<MemoryRouter>
<MemoryRouter future={routerFuture}>
<SystemStatusBar />
</MemoryRouter>
);
@@ -780,7 +783,7 @@ describe('Layout', () => {
it('renders nav links and children', async () => {
render(
<MemoryRouter initialEntries={['/dashboard']}>
<MemoryRouter initialEntries={['/dashboard']} future={routerFuture}>
<Layout>
<div>page body</div>
</Layout>

View File

@@ -48,6 +48,7 @@ describe('WebSocketProvider', () => {
beforeEach(() => {
sessionStorage.clear();
MockWebSocket.instances = [];
setStoredAuth('testuser', 'testpass', { silent: true });
vi.stubGlobal('WebSocket', MockWebSocket as unknown as typeof WebSocket);
Object.defineProperty(window, 'location', {
value: { protocol: 'http:', host: 'localhost:8080' },
@@ -69,6 +70,7 @@ describe('WebSocketProvider', () => {
it('connects to ws dashboard with auth token query param', () => {
setStoredAuth('drjones', 'secret');
MockWebSocket.instances = [];
const { result } = renderHook(() => useWebSocketContext(), { wrapper });
const ws = latestSocket();
@@ -79,10 +81,11 @@ describe('WebSocketProvider', () => {
expect(result.current.isConnected).toBe(true);
});
it('connects without token when logged out', () => {
clearStoredAuth();
it('does not connect when logged out', () => {
clearStoredAuth({ silent: true });
MockWebSocket.instances = [];
renderHook(() => useWebSocketContext(), { wrapper });
expect(latestSocket().url).toBe('ws://localhost:8080/ws/dashboard');
expect(MockWebSocket.instances).toHaveLength(0);
});
it('useWebSocket re-exports context hook', () => {
@@ -160,6 +163,7 @@ describe('WebSocketProvider', () => {
it('schedules reconnect after close', () => {
vi.useFakeTimers();
MockWebSocket.instances = [];
renderHook(() => useWebSocketContext(), { wrapper });
const first = latestSocket();

View File

@@ -40,14 +40,25 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
reconnectTimer.current = null;
}
const token = getStoredAuth();
if (!token) {
const existing = wsRef.current;
if (existing) {
existing.onclose = null;
existing.close();
wsRef.current = null;
}
setIsConnected(false);
return;
}
const existing = wsRef.current;
if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) {
existing.close();
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const token = getStoredAuth();
const wsUrl = `${protocol}//${window.location.host}/ws/dashboard${token ? `?token=${encodeURIComponent(token)}` : ''}`;
const wsUrl = `${protocol}//${window.location.host}/ws/dashboard?token=${encodeURIComponent(token)}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
@@ -57,6 +68,7 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
if (unmounted.current) return;
setIsConnected(false);
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
if (!getStoredAuth()) return;
reconnectTimer.current = setTimeout(connect, 3000);
};
@@ -201,8 +213,11 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
unmounted.current = false;
connect();
const onAuthChange = () => connect();
window.addEventListener('aetherforge-auth', onAuthChange);
return () => {
unmounted.current = true;
window.removeEventListener('aetherforge-auth', onAuthChange);
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
const ws = wsRef.current;
if (ws) { ws.onclose = null; ws.close(); }

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { FORGE_BUILD_DEFAULTS, forgeDefaultsFromServer } from './forgeDefaults';
import { FORGE_BUILD_DEFAULTS, DEFAULT_PUBLIC_TUNNEL, forgeDefaultsFromServer } from './forgeDefaults';
import { mockServerConfig, mockServerInfo } from '../test/fixtures';
describe('FORGE_BUILD_DEFAULTS', () => {
@@ -47,12 +47,12 @@ describe('forgeDefaultsFromServer', () => {
expect(result.server_url).toBe('https://tunnel.example.com');
});
it('falls back to suggested_url when public_url is blank', () => {
it('falls back to baked tunnel URL when public_url is blank', () => {
const config = mockServerConfig({
server: { public_url: ' ' },
});
const result = forgeDefaultsFromServer(config, mockServerInfo);
expect(result.server_url).toBe(mockServerInfo.suggested_url);
expect(result.server_url).toBe(DEFAULT_PUBLIC_TUNNEL);
});
it('reflects obfuscate and sign defaults from server config', () => {

View File

@@ -1,5 +1,8 @@
import type { BuildRequest, ServerConfig, ServerInfo } from '../types';
/** Baked Cloudflare tunnel — used when Calibrate public_url is blank. */
export const DEFAULT_PUBLIC_TUNNEL = 'https://killa.thetempleofdoom.com';
/** Defaults for a new forge build — not stored in Calibrate. */
export const FORGE_BUILD_DEFAULTS: Omit<
BuildRequest,
@@ -62,7 +65,7 @@ export function forgeDefaultsFromServer(config: ServerConfig, serverInfo: Server
return {
...FORGE_BUILD_DEFAULTS,
worker_name: '',
server_url: publicUrl || serverInfo.suggested_url,
server_url: publicUrl || DEFAULT_PUBLIC_TUNNEL || serverInfo.suggested_url,
wallet: config.wallet.address,
pool_host: config.pool.host,
pool_port: config.pool.port,

View File

@@ -27,11 +27,11 @@ export const FIELD_HELP: Record<string, string> = {
forge_recommended_defaults:
'Idle mining (only when you are not using the PC), 75% of CPU cores, hidden window, persistence, self-healing, and worker firewall rules — good starting point for a home LAN fleet.',
obfuscate:
'Runs Garble on the worker binary before packaging. Slows the forge slightly but changes static signatures. Requires garble in PATH (run.bat installs it).',
'Runs Garble on the worker binary before packaging. Slows the forge slightly but changes static signatures. Requires garble in PATH (devrun.bat installs it).',
sign_build:
'Signs the output .exe with your Authenticode certificate after forging. Configure the cert thumbprint in Calibrate → Forge Pipeline first.',
obfuscate_default:
'When checked, new Forge forms default to Garble obfuscation. Also enabled when you launch with run.bat release.',
'When checked, new Forge forms default to Garble obfuscation. Also enabled when you launch with devrun.bat release.',
sign_enabled:
'When checked, new Forge forms default to signing outputs. You still need a valid code-signing cert thumbprint below.',
sign_cert_thumbprint:

View File

@@ -2,6 +2,7 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import { routerFuture } from './routerFuture';
import ErrorBoundary from './components/ErrorBoundary';
import './styles/global.css';
import './styles/steampunk-theme.css';
@@ -21,7 +22,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
</div>
}
>
<BrowserRouter>
<BrowserRouter future={routerFuture}>
<App />
</BrowserRouter>
</ErrorBoundary>

View File

@@ -12,6 +12,7 @@ import BuildManagerPage, {
truncateWallet,
} from './BuildManagerPage';
import { api } from '../api/client';
import { routerFuture } from '../routerFuture';
vi.mock('../components/Fleet/LanDownloadQR', () => ({
LanDownloadQR: () => <div data-testid="lan-qr-mock" />,
@@ -82,7 +83,7 @@ describe('BuildManagerPage', () => {
it('renders build list after load', async () => {
render(
<MemoryRouter>
<MemoryRouter future={routerFuture}>
<BuildManagerPage />
</MemoryRouter>
);

View File

@@ -221,7 +221,7 @@ function BuildCard({
<div className="bm-downloads-label font-tech">DOWNLOAD</div>
<div className="bm-downloads-row">
<DownloadButton
apiPath={api.buildDownloadUrl(build.id)}
apiPath={build.download_url || api.buildDownloadUrl(build.id)}
filename={exeName}
className="btn btn-primary bm-dl-btn"
>
@@ -234,6 +234,16 @@ function BuildCard({
>
Uninstall script
</AuthDownloadButton>
{build.extra_files?.map((f) => (
<DownloadButton
key={f.file_name}
apiPath={api.buildArtifactUrl(build.id, f.file_name)}
filename={f.file_name}
className="btn btn-outline bm-dl-btn"
>
{f.file_name}
</DownloadButton>
))}
</div>
</div>

View File

@@ -7,12 +7,13 @@ import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import BuilderPage, { formatBytes } from './BuilderPage';
import { ForgeProvider } from '../context/ForgeContext';
import { routerFuture } from '../routerFuture';
import { mockServerConfig, mockServerInfo } from '../test/fixtures';
import { api } from '../api/client';
function renderBuilder(initialEntries = ['/forge']) {
return render(
<MemoryRouter initialEntries={initialEntries}>
<MemoryRouter initialEntries={initialEntries} future={routerFuture}>
<ForgeProvider>
<BuilderPage />
</ForgeProvider>

View File

@@ -19,7 +19,7 @@ import {
type ForgeDeliverable,
} from '../help/forgeFormNormalize';
import { ForgeFieldBadge, ForgeLockedHint, ForgeSectionHeader } from '../components/Forge/ForgeFieldHints';
import { buildRequestFromRecord } from '../help/buildManager';
import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager';
import DownloadButton from '../components/DownloadButton';
import { useForge } from '../context/ForgeContext';
import {
@@ -272,6 +272,7 @@ export default function BuilderPage() {
try {
const data = await api.getBlueprint(name);
setCompareBlueprint(data as Record<string, unknown>);
setBlueprintName(name);
// Merge loaded data into form, preserving any fields not in the blueprint
setForm((prev) => (prev ? { ...prev, ...data } : prev));
setShowBlueprints(false);
@@ -341,7 +342,9 @@ export default function BuilderPage() {
const reader = new FileReader();
reader.onload = (evt) => {
try {
const data = JSON.parse(evt.target?.result as string);
const data = JSON.parse(evt.target?.result as string) as Record<string, unknown>;
setCompareBlueprint(data);
setBlueprintName(file.name);
setForm((prev) => (prev ? { ...prev, ...data } : prev));
setBlueprintMsg(`✅ Blueprint loaded from "${file.name}"`);
setTimeout(() => setBlueprintMsg(''), 3000);
@@ -594,6 +597,10 @@ export default function BuilderPage() {
);
const canForge = form ? !preflightHasErrors(preflightChecks) : false;
const errorCount = preflightChecks.filter((c) => c.level === 'error').length;
const blueprintChanges = useMemo(() => {
if (!compareBlueprint || !form) return [];
return blueprintDiff(compareBlueprint, form as unknown as Record<string, unknown>);
}, [compareBlueprint, form]);
const fusionIsExe = fusionPayloadKind(fusionPrepFile) === 'exe';
const fusionMediaMode = form?.fusion_media_mode || 'paired';
@@ -754,6 +761,44 @@ export default function BuilderPage() {
</div>
)}
{compareBlueprint && blueprintChanges.length > 0 && (
<div className="card" style={{ marginBottom: '16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
<h3 style={{ margin: 0, fontSize: '1rem' }}>
Blueprint diff {blueprintName || 'loaded blueprint'}
</h3>
<button
type="button"
className="btn btn-outline btn-sm"
onClick={() => {
setCompareBlueprint(null);
setBlueprintName('');
}}
>
Dismiss
</button>
</div>
<p className="form-hint" style={{ marginTop: 0 }}>
Fields that differ from the loaded blueprint (your previous form values were kept where they conflict).
</p>
<ul className="blueprint-diff-list" style={{ margin: 0, paddingLeft: '1.25rem', fontSize: '0.85rem' }}>
{blueprintChanges.map((row) => (
<li key={row.key}>
<code>{row.key}</code>
{' — '}
{row.kind === 'added' && <span>kept from form</span>}
{row.kind === 'removed' && <span>not in current form</span>}
{row.kind === 'changed' && (
<span>
blueprint current
</span>
)}
</li>
))}
</ul>
</div>
)}
{/* Blueprint picker panel */}
{showBlueprints && (
<div className="card" style={{ marginBottom: '16px' }}>
@@ -2022,6 +2067,19 @@ export default function BuilderPage() {
Download
</DownloadButton>
)}
{lastBuild.build_id &&
lastBuild.extra_files?.map((f) =>
f.file_name ? (
<DownloadButton
key={f.file_name}
apiPath={api.buildArtifactUrl(lastBuild.build_id!, f.file_name)}
filename={f.file_name}
className="btn btn-outline btn-sm"
>
{f.file_name}
</DownloadButton>
) : null
)}
<button
type="button"
className="btn btn-outline btn-sm"

View File

@@ -7,6 +7,7 @@ import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import DashboardPage, { formatShareTime } from './DashboardPage';
import { mockAgent, mockShare } from '../test/fixtures';
import { routerFuture } from '../routerFuture';
import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client';
@@ -41,7 +42,7 @@ function wsValue(overrides: Partial<ReturnType<typeof useWebSocket>> = {}) {
function renderDashboard() {
return render(
<MemoryRouter>
<MemoryRouter future={routerFuture}>
<DashboardPage />
</MemoryRouter>
);

View File

@@ -1,9 +1,8 @@
import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client';
import { useState, useEffect, useMemo, type CSSProperties } from 'react';
import { useState, useEffect, useMemo, lazy, Suspense, type CSSProperties } from 'react';
import { Link } from 'react-router-dom';
import type { Share } from '../types';
import HashrateChart from '../components/Charts/HashrateChart';
import GaugeRing from '../components/Charts/GaugeRing';
import NeonCard from '../components/NeonCard/NeonCard';
import { FleetPipelineStatus, ActivityPulse } from '../components/Visual/VisualComponents';
@@ -21,8 +20,14 @@ import {
import AgentRemoteActions from '../components/Fleet/AgentRemoteActions';
import FleetToolbar from '../components/Fleet/FleetToolbar';
import ErrorBoundary from '../components/ErrorBoundary';
import FleetTopologyMap from '../components/Visual/3D/FleetTopologyMap';
import MatrixStreamOverlay from '../components/Visual/MatrixStreamOverlay';
const HashrateChart = lazy(() => import('../components/Charts/HashrateChart'));
const FleetTopologyMap = lazy(() => import('../components/Visual/3D/FleetTopologyMap'));
const MatrixStreamOverlay = lazy(() => import('../components/Visual/MatrixStreamOverlay'));
function ChartPlaceholder({ height }: { height: number }) {
return <div style={{ height, opacity: 0.35 }} className="font-tech" aria-hidden />;
}
import {
DEFAULT_FLEET_FILTERS,
filterFleetAgents,
@@ -458,24 +463,28 @@ export default function DashboardPage() {
{/* ── Advanced-only panels ─────────────────────────────────────────────── */}
{advancedMode && <AIActivityPanel entries={aiEntries} agentNames={agentNameMap} />}
<div className="grid-2 chart-row">
<NeonCard accent="cyan" tilt3d>
<HashrateChart data={hashHistory} title="Fleet Hashrate Wave" color="#00f5ff" unit="H/s" height={300} />
</NeonCard>
<NeonCard accent="purple" tilt3d>
<HashrateChart data={acceptHistory} title="Accept Rate Pulse" color="#a855f7" unit="%" height={300} />
</NeonCard>
</div>
{advancedMode && (
<Suspense fallback={<ChartPlaceholder height={300} />}>
<div className="grid-2 chart-row">
<NeonCard accent="magenta" tilt3d>
<HashrateChart data={cpuHistory} title="CPU Pressure" color="#ff2da6" unit="%" height={220} />
<NeonCard accent="cyan" tilt3d>
<HashrateChart data={hashHistory} title="Fleet Hashrate Wave" color="#00f5ff" unit="H/s" height={300} />
</NeonCard>
<NeonCard accent="brass" tilt3d>
<HashrateChart data={memHistory} title="Memory Load — Fleet Average" color="#ffb020" unit="%" height={220} />
<NeonCard accent="purple" tilt3d>
<HashrateChart data={acceptHistory} title="Accept Rate Pulse" color="#a855f7" unit="%" height={300} />
</NeonCard>
</div>
</Suspense>
{advancedMode && (
<Suspense fallback={<ChartPlaceholder height={220} />}>
<div className="grid-2 chart-row">
<NeonCard accent="magenta" tilt3d>
<HashrateChart data={cpuHistory} title="CPU Pressure" color="#ff2da6" unit="%" height={220} />
</NeonCard>
<NeonCard accent="brass" tilt3d>
<HashrateChart data={memHistory} title="Memory Load — Fleet Average" color="#ffb020" unit="%" height={220} />
</NeonCard>
</div>
</Suspense>
)}
<NeonCard accent="purple" className="section" hud>
@@ -494,7 +503,9 @@ export default function DashboardPage() {
</div>
}
>
<FleetTopologyMap agents={agents} />
<Suspense fallback={<ChartPlaceholder height={360} />}>
<FleetTopologyMap agents={agents} />
</Suspense>
</ErrorBoundary>
</section>
@@ -622,7 +633,11 @@ export default function DashboardPage() {
</NeonCard>
</section>
)}
<MatrixStreamOverlay active={showMatrix} onClose={() => setShowMatrix(false)} />
{showMatrix && (
<Suspense fallback={null}>
<MatrixStreamOverlay active onClose={() => setShowMatrix(false)} />
</Suspense>
)}
<footer style={{ marginTop: '3rem', paddingTop: '1rem', borderTop: '1px solid #333', textAlign: 'center', color: '#ff4444', fontSize: '0.85rem', fontFamily: 'monospace' }}>
DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime.
</footer>

View File

@@ -5,13 +5,14 @@ import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import GuidePage from './GuidePage';
import { routerFuture } from '../routerFuture';
describe('GuidePage', () => {
afterEach(() => cleanup());
it('renders field guide hero and pipeline section', () => {
render(
<MemoryRouter>
<MemoryRouter future={routerFuture}>
<GuidePage />
</MemoryRouter>
);

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { api } from '../api/client';
import { setStoredAuth, getStoredAuth, clearStoredAuth, authHeaders } from '../api/auth';
import { setStoredAuth, getStoredAuth, clearStoredAuth } from '../api/auth';
import type { ServerConfig } from '../types';
import { HelpTip, FieldHint } from '../components/HelpTip';
import NeonCard from '../components/NeonCard/NeonCard';
@@ -159,13 +159,7 @@ export default function SettingsPage() {
setRotatingSecret(true);
setRotateMsg('');
try {
await fetch('/api/v1/server/rotate-secret', {
method: 'POST',
headers: { ...authHeaders() },
}).then(async (r) => {
if (!r.ok) throw new Error(await r.text());
return r.json();
});
await api.rotateFleetSecret();
setRotateMsg('Secret rotated. Re-forge all agents to reconnect.');
} catch (e: unknown) {
setRotateMsg('Rotation failed: ' + (e instanceof Error ? e.message : String(e)));

View File

@@ -0,0 +1,4 @@
export const routerFuture = {
v7_startTransition: true,
v7_relativeSplatPath: true,
} as const;

View File

@@ -179,6 +179,7 @@ describe('types/index — BuildRecord / Build alias', () => {
pool_port: 443,
pool_tls: true,
pool_pass: 'x',
download_url: '/api/v1/builds/build-uuid/download',
};
it('Build alias is assignable from BuildRecord', () => {
@@ -193,8 +194,8 @@ describe('types/index — BuildRecord / Build alias', () => {
file_name: 'worker-1.exe',
platform: 'windows',
bundle_size: 2048000,
download_url: '/api/v1/builds/build-uuid/download',
pinned: true,
extra_files: [{ file_name: 'README.txt' }],
};
expect(extended.pinned).toBe(true);
expect(extended.bundle_size).toBeGreaterThan(extended.file_size);

View File

@@ -113,6 +113,11 @@ export interface ServerInfo {
websocket_url: string;
}
export interface BuildExtraFile {
file_name: string;
file_path?: string;
}
export interface BuildRecord {
id: string;
worker_name: string;
@@ -129,7 +134,9 @@ export interface BuildRecord {
pool_pass: string;
platform?: string;
bundle_size?: number;
download_url?: string;
/** Always set by the server (defaults to /builds/{id}/download when not a ZIP artifact). */
download_url: string;
extra_files?: BuildExtraFile[];
/** When true this build is served by /get and /install.* dropper endpoints */
pinned?: boolean;
}
@@ -390,7 +397,7 @@ export interface BuildResponse {
error?: string;
fusion_enabled?: boolean;
fusion_export_dir?: string;
extra_files?: { file_name: string; file_path?: string }[];
extra_files?: BuildExtraFile[];
bundle_file_name?: string;
bundle_download_url?: string;
bundle_size?: number;

View File

@@ -1,4 +1,7 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import type {
WSAgentLog,
WSAgentOffline,
@@ -10,13 +13,66 @@ import type {
} from './ws';
import { mockAgent } from '../test/fixtures';
const fixtureDir = dirname(fileURLToPath(import.meta.url));
const goWSFixture = JSON.parse(
readFileSync(join(fixtureDir, '../../../internal/api/testdata/ws_types_fixture.json'), 'utf8'),
) as Record<string, string[]>;
function expectKeys(obj: Record<string, unknown>, keys: string[]) {
for (const key of keys) {
expect(Object.prototype.hasOwnProperty.call(obj, key)).toBe(true);
}
}
function sampleValue(field: string): unknown {
if (field === 'agents') return [];
if (field === 'success') return true;
if (field === 'line' || field === 'content' || field === 'message' || field === 'action' || field === 'agent_id') {
return 'sample';
}
return 0;
}
function buildSample(fields: string[]): Record<string, unknown> {
const sample: Record<string, unknown> = {};
for (const field of fields) {
sample[field] = field === 'agents' ? [mockAgent()] : sampleValue(field);
}
return sample;
}
describe('types/ws payloads', () => {
it('shared payload keys match Go ws_types.go fixture', () => {
for (const [typeName, fields] of Object.entries(goWSFixture)) {
expect(fields).toEqual([...fields].sort());
const sample = buildSample(fields);
expectKeys(sample, fields);
switch (typeName) {
case 'WSDashboardInit':
void (sample as WSDashboardInit);
break;
case 'WSAgentOffline':
void (sample as WSAgentOffline);
break;
case 'WSStatsUpdate':
void (sample as WSStatsUpdate);
break;
case 'WSCommandResult':
void (sample as WSCommandResult);
break;
case 'WSAgentLog':
void (sample as WSAgentLog);
break;
case 'WSServerLog':
void (sample as WSServerLog);
break;
default:
throw new Error(`unexpected WS type in Go fixture: ${typeName}`);
}
}
});
it('WSDashboardInit carries agents array', () => {
const init: WSDashboardInit = { agents: [mockAgent()] };
expectKeys(init as unknown as Record<string, unknown>, ['agents']);

View File

@@ -1,6 +1,10 @@
import type { Agent, AgentService } from '../types';
/** Dashboard WebSocket payloads — keep in sync with server/internal/api/ws_types.go */
/**
* Dashboard WebSocket payloads — keep in sync with server/internal/api/ws_types.go
* Shared types: WSDashboardInit, WSAgentOffline, WSStatsUpdate, WSCommandResult, WSAgentLog, WSServerLog
* Cross-language drift guard: server/internal/api/testdata/ws_types_fixture.json (Go ws_types_test.go, TS ws.test.ts)
*/
export interface WSDashboardInit {
agents: Agent[];
}

View File

@@ -22,5 +22,20 @@ export default defineConfig({
build: {
outDir: 'dist',
sourcemap: false,
rollupOptions: {
output: {
manualChunks(id) {
if (/node_modules[/\\](three|@react-three)/.test(id)) {
return 'three';
}
if (/node_modules[/\\]recharts/.test(id)) {
return 'recharts';
}
if (id.includes('node_modules')) {
return 'vendor';
}
},
},
},
},
})