Final sweep: Crucible fixes, Path Tracer polish, forge progress, tests green.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled

Align dashboard subtitle default and UpsertAgent tests with fleet label behavior; WebSocket coalesce and PathForge hardening; Crucible expanded ops and visual DV fixes; Vitest 610/610 and full test-suite pass; trim PROBLEMS.md to open items only.
This commit is contained in:
AetherForge
2026-06-06 18:07:47 -07:00
parent e65753ce49
commit 6372b07e6c
40 changed files with 1495 additions and 794 deletions

View File

@@ -1,662 +1,90 @@
# PROBLEMS.md
Open issues only. Fixed items removed. Last sweep: 2026-06-06.
## By design / safety
### Open — medium / by design
| Issue | Notes | | Issue | Notes |
|-------|-------| |-------|-------|
| **`bof_execute` disabled** | Agent returns explicit error; in-memory BOF execution disabled (`client.go`). |
| **Process hollowing AMSI/ETW** | Relocation done; Defender/ETW ~50% failure; bypass not implemented (`hollow_windows.go`). |
| **Cloudflared in-process (non-Windows server)** | Stub on Linux/macOS; use external connector (`AF_TUNNEL_EXTERNAL`) or add launcher. |
| **macOS camera / GPU miner** | Stubs or partial; Linux has V4L2 + nvidia-smi path. |
| **KEV heuristics** | Non-Windows agents return `Status: n/a` (Windows-only CVE matching). |
| **Mesh P2P without `-tags p2p`** | Default build reports 0 peers (`mesh_p2p_stub.go`). |
| **Linux/macOS GPU RVN mining** | `detectGPU()` may find NVIDIA but miners download Windows `.exe` only. |
| **`bof_execute` disabled** | Agent returns explicit error — in-memory BOF execution disabled for safety (`client.go`). | ## Architecture deferred (large)
| **Process hollowing AMSI/ETW** | Relocation patching done; Defender/ETW detection causes ~50% failure — bypass not implemented (`hollow_windows.go`). |
| **Cloudflared on Linux server** | In-process tunnel start is Windows-only; Linux/macOS builds use no-op stub — use external connector (`AF_TUNNEL_EXTERNAL`) or add cross-platform launcher. |
| **macOS camera / GPU miner** | Stubs return "not supported"; Linux has V4L2 + nvidia-smi path. |
| **Agent WireGuard auto-download** | Windows `ensureWGExe()` downloads WireGuard on first Path Tracer use — heavy side effect; pre-install recommended. |
| **Non-Windows Path Tracer agent parity** | `pathtracer_stub.go` returns error on `wg_setup`; Linux/macOS agents cannot join WireGuard chains. |
| **KEV heuristics** | Non-Windows agents return catalog with `Status: n/a` — Windows-only CVE matching.
### Open — large / deferred
| Area | Notes |
|------|-------|
| **`tunnel_stream`** | Server-side TCP reverse relay documented as future (`README.md`). |
| **Path Tracer sessions** | `TraceSession` in handler RAM; restart loses state; no DB persistence or startup sweep. |
| **Non-Windows Path Tracer parity** | `pathtracer_stub.go` errors on `wg_setup`; chains are Windows-agent focused. |
| **NAT / symmetric UDP** | UPnP + DB IP fallback; no STUN/TURN or post-config connectivity probe. |
| **Fixed WireGuard port 51820** | Same UDP port all hops; multi-agent behind one NAT may conflict. |
| **Agent display name vs hostname** | WS `UpsertAgent` preserves operator rename when `name != hostname`; reconnect with hostname only keeps DB label. |
| **WireGuard auto-download (Windows)** | `ensureWGExe()` on first Path Tracer use; heavy, may need admin; pre-install recommended. |
| **Monolithic WebSocket context** | All `useWebSocket()` consumers re-render on any WS change; split contexts/selectors deferred. |
| **`CruciblePage` size (~2k lines)** | Terminal + fleet + tabs in one component; section split/memo deferred. |
| **Per-agent `stats_update` broadcast** | No batching in `websocket.go`; N agents → N dashboard frames. |
| **No CI HTTP forge** | `e2e-validate.ps1 -ForgeAgent` manual; live compile needs `LIVE_FORGE=1` + `-tags liveforge`. |
| **Path Forge test gaps** | Cancellation, batch races, skipped-counter UI not fully covered. |
| **Non-Windows forge host** | PE disguise / osslsigncode signing platform-limited by design. |
| **Mac PathForge runtime** | `.command` curl `/api/download/agent-mac`; needs reachable `server_url` + binary on server. |
| **Terminal virtualization** | 400-line DOM cap only; full virtual scrollback deferred. |
| **Vite chunk weight** | `three` + vendor warnings; FleetTopologyMap lazy but heavy first open. |
## Open bugs / behavior
| Issue | Notes | | Issue | Notes |
|-------|-------| |-------|-------|
| **Unknown Unix CPU stats stub** | `cpu_stub.go` may return 0 and break idle-mining guard on exotic platforms. |
| **Non-Windows agent parity** | `pathtracer_stub.go` returns error JSON on `wg_setup`; Linux/macOS cannot join chains. UI filters platform; server does not validate `platform` field. | | **Linux headless screenshot** | Needs `xvfb` + scrot or custom `command` in containers. |
| **In-memory sessions only** | `TraceSession` map in handler RAM — server restart loses session state while agents may still hold tunnels; no DB persistence or startup sweep. | ## UX / visual (unfixed DV)
| **WireGuard auto-download** | Windows `ensureWGExe()` silently downloads/installs WireGuard on first use — heavy, needs admin, no progress UI. | | ID | Issue |
|----|-------|
| **No PathTracerPage Vitest** | Page logic (polling, chain selection, QR modal) untested; only `uiHelp.test.ts` covers help keys. | | DV-01 | Neon cyan fragmentation (`#00f5ff`, `#0ff` vs token `#00e8f5`) across several components. |
| DV-02 | Page header patterns diverge (Build Manager, Path Tracer green title, Forge naming). |
| **No agent-side pathtracer tests** | `pathtracer_windows.go` / stub have zero Go tests. | | DV-03 | `SacredPageHeader` unused (dead CSS path). |
| DV-04 | `Pages.css` duplicate `.empty-state` / `.page-header h1` override order. |
| **NAT / symmetric UDP** | UPnP + DB IP fallback may still yield unreachable endpoints; no STUN/TURN or connectivity probe after configure. | | DV-07 | Path Tracer visual island (`#00ffaa`) vs operator-deck chrome. |
| DV-09 | Dead chart badge styles in wealth-deck CSS. |
| **Fixed listen port 51820** | All hops use same UDP port — multiple agents behind one NAT may conflict; no per-hop port allocation. | | DV-13 | Dark-only; no light / `prefers-color-scheme` path. |
| DV-14 | Sidebar footer hard-coded `v0.0.1`. |
| **Agent display name vs hostname** | WS auth `UpsertAgent` overwrites `name` with hostname on every connect — operator-renamed fleet labels may not appear in Path Tracer unless re-saved after connect. | | DV-15 | `MissionDeckPage.tsx` excessive blank lines (maintainability). |
## Help / product copy gaps
### Open — medium / UX | ID | Issue |
|----|-------|
| UH-03 | Emberwake / War Room widgets need `HelpTip` parity with Command Deck funnel. |
| UH-04 | Mission Deck minimal operator guidance. |
| Issue | Notes | | UH-05 | Builder advanced forge sections missing some `HelpTip` keys (`docAnchors.test` gap list). |
|-------|-------| ## Test gaps / noise
| **Visual island (green vs deck)** | Page uses `#00ffaa` WireGuard chrome while `data-operator-deck='pathtracer'` sets blue accent (`operatorDeck.css`); intentional branding split (see DV-07). |
| **Status polling only** | UI polls `/status` every 2s — no WebSocket push for hop progress; acceptable latency but stale during orchestration. |
| **Error path leaves tracing UI** | On `status.error`, `tracing` clears but session remains server-side until operator clicks End or TTL — hops show failed state in chain panel. |
---
### Open — large / deferred
| Issue | Notes |
|-------|-------|
| **No CI-automated HTTP forge** | `scripts/e2e-validate.ps1 -ForgeAgent` still manual (multipart fusion, long compile). Live compile gated behind `LIVE_FORGE=1` + `-tags liveforge`. |
| **PathForge test gaps** | Only `TestPathForgePlacedExcludesHintFile`; no skipped-counter, lock-original, or frontend UI tests. |
| **Forge cancellation / batch races** | Cancel token API tested in isolation; concurrent batch forge + mid-compile cancel not covered. |
| **Non-Windows forge host limits** | PE disguise (`disguise_stub.go`) and Windows signing (`sign_stub.go` / osslsigncode) are platform-constrained by design. |
| **Mac PathForge runtime dependency** | `.command` launchers curl `/api/download/agent-mac` at runtime — requires reachable `server_url` and pre-placed agent binary. |
### Open — low
| Issue | Notes |
|-------|-------|
| ~~\pathforge_test.go\ dead loop~~ | **Fixed.** Replaced the no-op loop with meaningful assertions: each entry Files must contain the hint file and at least one launcher companion, confirming the hint is placed but not counted in Placed. Pre-existing err := redeclaration compile error in pathforge.go L128 also fixed. |
### Open (document-only / deferred)
| Issue | Notes |
|-------|--------|
| Dual storage without sync policy | Complex cross-tab sync — session preferred over local; `aetherforge-auth` event on logout |
| Flaky progress simulation vs. real compile time | Cosmetic — stage timeline caps at 94% until server responds (45 min client timeout) |
| Path Forge / batch fusion test gaps | Cancellation, partial batch failure, cancel-token races — needs dedicated tests |
| DashboardPage tests emit ECONNREFUSED stderr | Failure-path tests; happy-dom hits `localhost:3000`; tests pass |
| DownloadButton mock aliasing pattern | Document for new download helpers — shared mock fn already in `components.test.tsx` |
--
### Open (2026-06-06 audit — LARGE)
- **`tunnel_stream` not implemented** — server-side TCP reverse relay documented as future work (`README.md`).
- **Server `cloudflared` launcher no-op on non-Windows** — Linux server deploy cannot auto-launch tunnels (`cloudflared/launcher_stub.go`).
### Low (open)
- ~~**`mergeConfig` partial-PUT `UseTLS` legacy behavior**~~ **Clarified.** The API PUT handler uses `mergeConfigExplicit` with a field-mask so absent keys never reset booleans. The legacy `mergeConfig` (file-load fallback only) unconditionally copies bools -- documented with a header comment in config.go. Test at config_test.go L526-528 updated to assert the expected behavior and explain the distinction.
### High (open)
- **Non-Windows Path Tracer agent parity** — `pathtracer_stub.go` returns error on `wg_setup`; Linux/macOS agents cannot join WireGuard chains.
### Medium (open)
- **Agent WireGuard auto-download:** Windows agent `ensureWGExe()` downloads and silently installs WireGuard from `download.wireguard.com` on first Path Tracer use if not already present (`agent/client/pathtracer_windows.go`). Heavy side effect; no server-side fix — operator should pre-install WireGuard on fleet hosts or accept first-run download latency.
- Mac PathForge `.command` still depends on `server_url` + `/api/download/agent-mac` at runtime (now validated at forge time).
| Op | Command | Notes |
|----|---------|-------|
| SMB share enumeration | `smb_shares` | Windows + Remote Aggressive; ARP/subnet hosts ? `net view` JSON |
| Spread status | `spread_status` | In-memory last sweep (`deploy/spread_status.go`); read-only |
| Credential names | `credential_vault_list` | Win Credential Manager / macOS Keychain / Linux secret-tool + `~/.ssh` paths — names only |
| Secure wipe | `secure_wipe` | Overwrite-then-delete folder; system-root guards; confirm in UI |
| Port-forward matrix | `tunnel_ssh_forward` × N | `CruciblePortForwardMatrix` — multi-row grid on selected Windows nodes |
UI: Phase C controls in `CrucibleExpandedOps.tsx` Fleet Maintenance (replaces “coming soon” stubs).
---
### Linux / macOS parity
| Area | Status |
|------|--------|
| **Mining + hashrate** | RandomX pure-Go engine works on Linux/macOS; stats loop sends `hashrate_15s/1m/15m` + shares over WS. |
| **Idle schedule guard** | **Fixed** — `SystemCPUPercent` was always 0 on Unix (`reporter_unix.go`), blocking idle-mode mining; Linux uses `/proc/stat`, macOS uses `sysctl kern.cp_time`. |
| **Screenshot** | Linux: scrot / ImageMagick `import` / gnome-screenshot. macOS: `screencapture`. |
| **Camera** | Linux V4L2 via ffmpeg/fswebcam (`camera_linux.go`). macOS: stub. |
| **File ops** | Cross-platform (`file_ops_unix.go` / `file_ops_windows.go`). |
| **Posture** | Unix collectors return firewall/AV/patch data (`posture_unix.go`), not all n/a. |
| **Spread** | SSH path on Linux/macOS (`autospread_unix.go`); SMB/WinRM Windows-only by design. |
| **Firewall ops** | Linux ufw/iptables (`firewall_linux_ops.go`); **macOS pf + socketfilterfw** (`firewall_darwin_ops.go`); other Unix stubs. |
| **GPU miner** | Windows-only T-Rex path; Linux/macOS stub with detect-only. |
| **Docker E2E** | `docker/docker-compose.yml` — isolated agent + server; see `docker/README.md`. |
### Open
- **Client:** WebSocket/beacon paths integration-only in CI (Docker Tier 2 closes Linux slice).
- **macOS:** camera, GPU miner — stubs or partial; firewall aggressive ops implemented (see Backend BE-04).
- **Linux screenshot:** headless containers need `xvfb` + scrot or custom `command` field.
- **`bof_execute` permanently disabled** — handler always fails (`client.go`); product/safety decision.
- **Linux/macOS GPU RVN mining broken** — `detectGPU()` may find NVIDIA, but `spec()` downloads Windows `.exe` miners (`gpu_miner.go`, `gpu_detect_stub.go`).
- **Mesh P2P without `-tags p2p`** — default build reports 0 peers; UI exposes `mesh_status` with re-forge hint (`mesh_p2p_stub.go`).
- **Process hollowing AMSI/ETW bypass not implemented** — documented ~50% failure rate (`hollow_windows.go`).
- **KEV exposure scan non-Windows** — all CVEs marked `n/a` (`cve_scan_stub.go`).
- **Unknown Unix CPU stats stub returns 0** — can break idle-mining guard on exotic platforms (`cpu_stub.go`).
### Open (document-only / deferred)
| Issue | Notes |
|-------|--------|
| Dual storage without sync policy | Complex cross-tab sync — session preferred over local; `aetherforge-auth` event on logout |
| Flaky progress simulation vs. real compile time | Cosmetic — stage timeline caps at 94% until server responds (45 min client timeout) |
| Path Forge / batch fusion test gaps | Cancellation, partial batch failure, cancel-token races — needs dedicated tests |
| DashboardPage tests emit ECONNREFUSED stderr | Failure-path tests; happy-dom hits `localhost:3000`; tests pass |
| DownloadButton mock aliasing pattern | Document for new download helpers — shared mock fn already in `components.test.tsx` |
| ~~`server/webroot` not auto-synced on `npm run build`~~ | **Fixed.** Changed `vite.config.ts` `build.outDir` from `dist` to `../webroot` (with `emptyOutDir: true`). `npm run build` now writes directly to `server/webroot/` -- no manual copy step required. |
| Vitest stderr noise | `FleetTopologyMap` three.js tags warn in happy-dom — tests pass |
---
### Open (needs product copy or broader pass)
| ID | Issue | Notes |
|----|-------|-------|
| ~~UH-01~~ | ~~Crucible expanded ops (spread/tunnels/recon buttons)~~ | **Fixed:** Added `HelpTip` to the four most confusing individual buttons (Spread Now, Subnet Scan, Hole Punch, Start Tunnel) in `CrucibleExpandedOps.tsx`, plus 10 new keys in `uiHelp.ts`. All section headers already carry `helpField` via `CrucibleCollapsibleSection`. |
| ~~UH-02~~ | ~~Fleet Roster / Agents page bulk toolbar~~ | **Fixed:** Added `HelpTip` to filter row (`fl_filter_chips`), bulk-action bar (`fl_bulk_actions`) in `FleetToolbar.tsx`, and Groups label (`fl_groups`) in `FleetGroupsStrip.tsx`. |
| UH-03 | Emberwake / War Room campaign widgets | Funnel stages need `HelpTip` parity with Command Deck funnel |
| UH-04 | Mission Deck | Page has minimal operator guidance |
| UH-05 | Builder mission wizard chips | Inline blurbs exist on chips; not all advanced forge sections have `HelpTip` (see `docAnchors.test` gap list) |
| ~~UH-06~~ | ~~Settings tabs beyond Calibrate/Forge~~ | **Fixed:** Added `HelpTip` to Fleet Alerts heading (`set_alerts`), Alert Notifications heading (`set_alert_notifications`), and Webhook URL field (`set_webhook`) in `SettingsPage.tsx`. |
### Open (visual / UX debt)
| ID | Issue | Files / notes |
|----|-------|----------------|
| DV-01 | **Neon cyan fragmentation** — canonical token is `--neon-cyan: #00e8f5` but components hard-code `#00f5ff`, `#00e5ff`, and `#0ff` fallbacks | `AgentRemoteActions.css`, `HashrateChart.tsx`, `BuildManagerPage.tsx`, `ProtocolTunnelPanel.css`, `FileManager.css` (fixed), sacred geometry SVGs |
| DV-02 | **Page header patterns diverge** — most pages use `deck-hero` + eyebrow; Build Manager uses `bm-header` / `bm-title`; Path Tracer uses green `.pt-title` (`#00ffaa`); Forge says “The Forge” vs nav “Forge” | `BuildManagerPage.tsx/css`, `PathTracerPage.css`, `BuilderPage.tsx`, `Layout.tsx` NAV |
| DV-03 | **`SacredPageHeader` unused** — component + `.page-header--sacred` CSS exist but no page imports it; dead design path | `SacredPageHeader.tsx`, `sacred-geometry.css` |
| DV-04 | **`Pages.css` duplicate / conflicting rules** — two `.empty-state` blocks (L192 vs L1078); second `.page-header h1` block overrides `visual-polish` gradient when page CSS loads after global polish | `Pages.css`; load order via per-page imports |
| DV-05 | **Default dashboard subtitle** — out-of-box copy is “security is just an emotion” until Calibrate overrides `dashboard_subtitle`; reads as placeholder to new operators | `DashboardPage.tsx` L70; `SettingsPage.tsx` L131 |
| DV-06 | **Path Tracer buried in nav** — desktop sidebar lists it last; mobile hides it under “More” while Crucible/Forge are primary tabs | `Layout.tsx` MOBILE_PRIMARY vs MOBILE_MORE |
| DV-07 | **Path Tracer visual island** — green WireGuard aesthetic (`#00ffaa`) does not use operator-deck page classes or brass/neon deck chrome | `PathTracerPage.css`, `PathTracerPage.tsx` (missing `operator-deck-page`) |
| DV-08 | **Chart lazy-load placeholder is invisible** — `ChartPlaceholder` is an empty div at 35% opacity; advanced charts pop in with layout shift | `DashboardPage.tsx` L51–53 |
| DV-09 | **Dead chart badge styles** — `.chart-live.sample` / `.blend` in wealth-deck CSS; `resolveChartSeries` no longer emits sample mode | `wealth-deck.css`; `chartSampleData.ts` |
| DV-10 | **Docs vs in-app naming drift** — wiki says “Calibrate (Settings)”, “Command Deck”, “Forge / Builder”; nav uses “Calibrate”, “Command Deck”, “Forge”; Emberwake route was `/spread` redirect | `public/docs/index.html`, `Layout.tsx`, `App.tsx` |
| DV-11 | **Public spread landing vs Emberwake** — `/spread/` static kit uses `aether.css` deck tokens (good) but typography/spacing differs slightly from in-app Emberwake cards | `public/spread/assets/aether.css`, `EmberwakePage.css` |
| DV-12 | **Emoji in status bar / actions** — ?? DOCS pill, agent action buttons (?? ? ?) inconsistent with otherwise SVG-icon nav | `SystemStatusBar.tsx`, `AgentRemoteActions.tsx` |
| DV-13 | **No light theme** — entire product is dark-only; docs wiki matches but no `prefers-color-scheme` path | global styles |
| DV-14 | **Sidebar version hard-coded** — footer shows `v0.0.1` regardless of server build | `Layout.tsx` L321 |
| DV-15 | **Mission Deck formatting** — source file has excessive blank lines (likely formatter artifact); harder to maintain, no runtime impact | `MissionDeckPage.tsx` |
### Remaining doc gaps (need product copy / user input)
| Topic | Notes |
|-------|-------|
| Default dashboard subtitle | “security is just an emotion” until Calibrate override — marketing copy decision (DV-05) |
| SocGholish / fake-update lander | Documented in spread playbook `#third-party` tab; no shipped branded HTML template |
| OAuth redirect abuse playbook | Research only — no Entra app wizard in Emberwake |
| Earnings USD quote | `TEST_RESULTS.md` notes low priority; not wired |
| Screenshot placeholders in wiki | `[Screenshot: …]` divs — need real captures from operator deck |
| Sidebar version `v0.0.1` | Hard-coded in `Layout.tsx` — should read server build version (DV-14) |
| Light theme | Dark-only documented; no `prefers-color-scheme` path (DV-13) |
---
### Deferred — needs refactor, heavy mocks, or external deps
| Area | Why deferred |
|------|--------------|
| `agent/cmd/mine-validate` | Standalone CLI (`main` package); exercises RandomX + live Stratum; run manually or in mining CI |
| Cloudflared `Start` with real token | Spawns/downloads binary + network; only empty-token path covered |
| `FleetScheduler.Start` loop | 1-minute ticker; logic covered via direct `tickInterval`/`tickCron` in same-package tests |
| CruciblePage full integration | Large page; helpers + `CrucibleExpandedOps` + `FileManager` tested separately |
| Fleet panel widgets | Partially covered via `components.test.tsx`; full panel flows need WS mocks |
| Path Forge / batch fusion cancel races | Needs dedicated cancel-token harness |
| Playwright E2E (`server/web/e2e/`) | Phase 8 of test-suite; requires build + temp server |
| Docker mining E2E | `.github/workflows/ci-docker-mining.yml` — separate tier |
| Mesh P2P | Requires `-tags p2p` build tag |
| Platform-specific fusion launchers | OS-gated; crypto path covered in `media_crypto_test.go` |
---
| Suite | Result | Notes |
|-------|--------|-------|
| `go test ./...` (repo root) | **N/A** | No root `go.mod`; use per-module dirs (documented in Integration audit). |
| `agent/` | **PASS** | All packages ok (`client` ~3s). |
| `server/` | **PASS** | `internal/api` ~69s, `internal/builder` ~32s. |
| `fusion/` | **PASS** | Includes `media_crypto_test.go` (untracked in git at time of pass). |
| `server/web` Vitest | **PASS** | 66 files / 587 tests (3 consecutive full runs). |
| Item | Notes | | Item | Notes |
|------|-------| |------|-------|
| Agent pathtracer Go tests | Windows impl + stub have limited coverage (`pathtracer_stub_test.go` started). |
| Client WS/beacon paths | Integration-heavy; Docker Tier 2 covers Linux slice only. |
| Path Tracer 2s REST poll | No WS hop progress; acceptable latency, extra load while tracing. |
| Emberwake double feed | 15s client poll + 30s server war-room broadcast; prefer WS-only. |
| `SystemStatusBar` REST poll | `listAgents` every 15s duplicates WS fleet stream. |
| Builder / dashboard failure tests | Vitest emits ECONNREFUSED stderr on happy-dom; tests pass. |
| Download mock pattern | Prefer separate `vi.fn()` per `api/download` export to avoid flakes. |
| Vitest stderr `ECONNREFUSED 127.0.0.1:3000` | Failure-path / bulk-command tests in `DashboardPage`, `AgentsPage`, etc. (see Dashboard section). | ## Product decisions (document-only)
| `FleetTopologyMap` three.js ref warnings | happy-dom; cosmetic stderr. |
| Vite build chunk size warnings | `three` / vendor bundles > 500 kB; not a test failure. |
---
## Bugs — Builder/Security (Bug Team 4 audit, 2026-06-06)
*Scope: `server/internal/builder/`, `fusion/`, `server/config.go`, cross-cutting API input validation and auth edge cases.*
### Fixed in this pass
| ID | Fix |
|----|-----|
| BLD-01 | **PowerShell injection in uninstaller** — `processName` and `persistenceKey` were embedded in single-quoted PS1 strings without escaping apostrophes. A `WorkerName` like `foo'; Invoke-Expression …; '` would break out of the string. Now uses `strings.ReplaceAll(…, "'", "''")` for both fields, consistent with how `installRel` was already escaped (`uninstall.go`). |
| BLD-02 | **JSON build request body unbounded** — non-multipart `POST /builder/build` decoded `r.Body` without a size limit; a 1 GiB JSON body would buffer entirely. Fixed: `http.MaxBytesReader` capped to 512 KiB before `json.Decode` (`handler.go`). |
| BLD-03 | **Unbounded backup pool/URL arrays** — `BackupServerURLs`, `BackupPools`, and `RVNBackupPools` from user input are concatenated verbatim into the generated Go source (`generateBuiltinConfig`). A request with 10 000 entries would produce a multi-MB `.go` file, slowing or crashing `go build`. Fixed: arrays truncated to 10 entries each in `normalizeRequest` (`handler.go`). |
| BLD-04 | **Partial build dir not cleaned on failure** — when `compileWorker`, `buildFusionFromRequest`, `writeUninstallScript`, or `MkdirAll` fail mid-build, the entire `builds/<uuid>/` tree (containing a copy of agent source + uploaded fusion payload) was left on disk. Added `cleanupBuild()` closure that calls `os.RemoveAll(buildDir)` on each failure return path (`handler.go`). |
| BLD-05 | **Weak random password entropy** — `generateRandomPassword()` used only 4 random bytes (8 hex chars, 32-bit entropy), guessable in ~4 billion attempts. Increased to 8 bytes (16 hex chars, 64-bit entropy) (`router.go`). Test updated. |
| BLD-06 | **Malformed `config.json` silently ignored** — `json.Unmarshal` failure was swallowed; operator saw no indication their config was rejected and defaults were running instead. Added `fmt.Fprintf(os.Stderr, …)` warning on parse failure (`config.go`). |
### Deferred / large
| ID | Severity | Location | Description |
|----|----------|----------|-------------|
| ~~**BLD-D1**~~ | ~~High~~ | `server/internal/builder/pathforge.go` | **FIXED.** Added `validateRootPath` to `PathForgeHandler.ServeHTTP`: rejects any `root_path` containing `..` segments and enforces an allowlist of safe prefixes (server `dataDir`, user home directory, OS temp directory) via `isAllowedRootPath` / `isPathUnder`. Paths outside these prefixes return HTTP 400. |
| ~~**BLD-D2**~~ | ~~Medium~~ | `server/internal/builder/pathforge.go` `batContent` / `macContent` | **FIXED.** Added four escaping helpers — `escapeBat` (`%``%%`, `"``\"`), `escapeBatPS` (adds `'``''` for PowerShell single-quoted strings inside a cmd.exe `-Command` argument), `escapeShDouble` (`\`, `"`, `$`, `` ` `` backslash-escaped for bash double-quoted strings), `escapeShSingle` (`'`→`'\''` for bash single-quoted strings). Applied: `batContent` uses `escapeBat` for `ren`/`start` arguments and `escapeBatPS` for the embedded PowerShell `-Command` string; `macContent` uses `escapeShDouble` for filenames and `escapeShSingle` for `serverURL`. |
| ~~**BLD-D3**~~ | ~~Low~~ | `server/internal/builder/build_universal.go` | ~~**No partial build cleanup for universal builds.**~~ **Fixed.** Added `cleanupBuild := func() { _ = os.RemoveAll(buildDir) }` at the top of `buildUniversalAgent`, `finishSpreadKit`, and `finishUniversalFusion`, and called it on every failure return, matching the BLD-04 pattern. Also fixed a pre-existing `err :=` → `err =` redeclaration compile error in `pathforge.go` L128 that was blocking all builder test compilation. |
---
## Bugs — Server (Bug Team 1 audit, 2026-06-06)
### Deferred / large
| ID | Severity | Location | Description |
|----|----------|----------|-------------|
| ~~**SRV-B1**~~ | ~~High~~ | `server/main.go` | ~~**No graceful shutdown on SIGINT/SIGTERM.**~~ **Fixed.** `http.ListenAndServe` replaced with `http.Server` + goroutine; `signal.NotifyContext(syscall.SIGINT, syscall.SIGTERM)` drives a `srv.Shutdown(ctx)` with 10 s timeout on signal, allowing all `defer` calls (`cloudflared.Stop()`, `database.Close()`, `maintenance.StopRetentionJobs()`, `fleetSched.Stop()`) to run cleanly. |
| ~~**SRV-B2**~~ | ~~Medium~~ | `server/internal/db/sqlite.go` | ~~**SQLite max-connections not configured.**~~ **Fixed.** `db.SetMaxOpenConns(1)` added immediately after `sql.Open`; the single-connection pool eliminates concurrent-writer WAL-lock contention and `SQLITE_BUSY` errors under load. |
| ~~**SRV-B3**~~ | ~~Medium~~ | `server/internal/scheduler/fleet_scheduler.go` | ~~**O(tasks × agents) DB queries per minute.**~~ **Fixed.** `BulkLastFleetTaskRuns` added to the `db` package; `tickInterval` now pre-fetches all relevant `fleet_task_runs` rows in a single query and checks an in-memory `map[string]time.Time` (keyed `"agentID:taskID"`) in the nested loop — 1 query per tick instead of tasks × agents. |
| ~~**SRV-B4**~~ | ~~Medium~~ | `server/internal/api/websocket.go` `broadcastDashboard` | ~~**Stale-conn cleanup races with `HandleDashboardWS` teardown.**~~ **Fixed.** `broadcastDashboard` no longer spawns a goroutine to delete the dashboard map entry on write failure. It only closes the connection; `HandleDashboardWS` already owns all map cleanup via its existing `defer`, so the double-delete and the spurious `presence_update{online:false}` are eliminated. |
| ~~**SRV-B5**~~ | ~~Low~~ | `server/internal/api/agent_ws_limiter.go` | ~~**Rate-limiter map never purges zero-entry keys.**~~ **Fixed.** `delete(agentWSRateLim.attempts, clientIP)` called when `len(filtered) == 0` after the expiry sweep; map keys are reclaimed as IP addresses churn out of the window. |
---
## Backend
- **`db.New` / `MkdirAll`:** Already returns error on failure (`server/internal/db/sqlite.go`); remove stale “Low (open)” note in Server API section when editing that doc block.
---
## Frontend
- **`RemoteDirBrowser` removal:** No broken imports. UI logic lives in `FileManager.tsx` + `src/help/remoteDirBrowser.ts`; `remoteDirBrowser.test.ts` covers helpers. Deleted `RemoteDirBrowser.tsx` / `.css` are not referenced elsewhere.
- **`FileManager.test.tsx`:** Untracked but picked up by Vitest (`src/**/*.test.{ts,tsx}`); 10 tests pass — add to git when committing Fleet work.
- **Download test mocks:** Prefer separate `vi.fn()` per export when mocking `api/download` (real module aliases `downloadApiFile` to `downloadAuthedFile`; shared mock caused order-dependent flakes).
---
### Open — large / architecture
| Issue | Notes |
| Topic | Notes |
|-------|-------| |-------|-------|
| Dual storage sync | Session vs localStorage; `aetherforge-auth` on logout; no full cross-tab policy. |
| Forge progress simulation | Stage timeline caps ~94% until server responds (45 min client timeout). |
| MatrixRain / CursorFire | Layout mounts effects on all routes; route-gating deferred. |
| CI scope | `.github/workflows/ci-docker-mining.yml` only; no root Makefile test target. |
| **Monolithic WebSocket context** | Every `useWebSocket()` consumer re-renders on any WS state change. Split into `FleetContext` / `EventsContext` or selector hook (`useAgents()`) for true isolation. | ## Do not commit
| **`CruciblePage` size (~2k lines)** | Single component owns terminal, fleet list, tabs, file manager — hard to memoize subtrees; consider section components + `React.memo` boundaries. |
| **Per-agent `stats_update` broadcast** | Backend sends one dashboard message per agent stats tick; no batching/coalescing in `websocket.go`. Fleet of N agents ? N JSON parses/frame on client. |
| **`SystemStatusBar` REST poll duplicates WS** | Polls `listAgents` every 15s though fleet already streams via WebSocket — wire readout to WS or drop agent poll. |
| **Vite chunk size** | `three` (~600 kB) and vendor bundles trigger build warnings; FleetTopologyMap loads three on Dashboard — already lazy but still heavy first open. |
| **No terminal virtualization** | DOM cap at 400 lines helps; full virtual list (react-window) needed for 2000-line scrollback without mount cost. |
| **MatrixRain always mounted in Layout** | Runs on every route including mobile (hidden sidebar but component still mounts on desktop). Consider `content-visibility` or route-gated mount. |
| **Path Tracer 2s REST poll** | No WS push for hop progress; acceptable but adds load during orchestration. |
### Open — medium
| Issue | Notes |
|-------|-------|
| **Emberwake war room double feed** | 15s client poll + 30s server `runWarRoomBroadcast` — redundant; prefer WS-only with poll fallback. |
| **CursorFire + SacredGeometry on all routes** | Desktop-only effects still mount with Layout; gate on `VisualEffectsContext` or route. |
| **Earnings estimate on every hashrate change** | Dashboard debounces via `totalHashrate` effect — could share chart sampler interval. |
---
### Deferred — large / complex
| Issue | Location | Notes |
|-------|----------|-------|
| ~~**WebSocketProvider async race**~~ | `context/WebSocketProvider.tsx` | **Fixed (2026-06-06).** Added `openingRef` (`useRef(false)`) in-flight guard -- a second `connect()` call while a ticket fetch is in progress returns early. Added `AbortController` (`ticketAbortRef`) to cancel any prior in-flight fetch; aborted invocations bail before creating a `WebSocket`. `openingRef` is always reset in a `finally` block. |
| ~~**`useVisibleInterval` calls `fn` on every dep change**~~ | `hooks/usePageVisible.ts` | **Fixed (2026-06-06).** `fn` is now stored in a `fnRef` (`useRef`). The effect depends only on `ms`, `enabled`, and `visible` -- an unstable `fn` reference no longer re-runs the effect or triggers an extra immediate call. The interval always invokes `fnRef.current()` so callers always see the latest `fn` without extra renders. |
---
### Deferred — large / medium
| ID | Issue | Location | Notes |
|----|-------|----------|-------|
| ~~BA-03~~ | ~~**`write()` has no write deadline**~~ | `client/client.go` — `write()` | **Fixed.** `c.conn.SetWriteDeadline(time.Now().Add(15*time.Second))` is now called immediately before `WriteJSON` (and cleared afterward), so a stalled TCP socket cannot hold `c.mu` indefinitely and deadlock share submission, stats, and command-result goroutines. |
| ~~BA-04~~ | ~~**SSH/SCP spread commands have no overall timeout**~~ | `deploy/autospread_unix.go` — `attemptSSHSpread()` | `ConnectTimeout=3` limits only the TCP handshake; after a successful connection, `scp.Run()` and `ssh … start.Run()` have no deadline. A slow or unresponsive host stalls the goroutine indefinitely, holding a slot in `spreadSem` (16 total). With 16 such hangs in flight, all future spread goroutines block waiting on the semaphore. Fix: use `exec.CommandContext` with a ~30 s deadline wrapping the whole SCP + SSH sequence. **Fixed:** `exec.CommandContext` with `context.WithTimeout(30s)` wraps the full SCP + SSH sequence; a hung host releases its semaphore slot after 30 s. |
| ~~BA-05~~ | ~~**GPU miner binary download has no HTTP timeout or body-size cap**~~ | `client/gpu_miner.go` — `downloadAndExtract()` | `http.Get(url)` with no timeout and `io.ReadAll(resp.Body)` with no size limit. A slow redirect or a response that trickles bytes forever will hang the goroutine; a gigabyte-scale response could OOM the agent. Fix: use an `http.Client` with a 5-min overall timeout, and wrap the body in `io.LimitReader(resp.Body, 512<<20)`. **Fixed:** `http.Client{Timeout: 5*time.Minute}` + `io.LimitReader(resp.Body, 512<<20)`. |
| ~~BA-06~~ | ~~**`CollectFullSysCheck` blocks for 45+ s on empty subnets**~~ | **Fixed (simpler approach):** Reduced `maxHosts` from 56 to 20 in `ScanLocalSubnet()` call in `syscheck.go`. Caps worst-case scan at ~16 s on an empty /24. No API change needed. |
---
## Infrastructure
- **Root `fix.py`, `fix3.py`, `fix4.py`:** Untracked one-off Python string/regex editors targeting `CruciblePage.tsx` (ProtocolTunnel `onDispatch`, GPU hashrate label). Intended changes appear already applied in `CruciblePage.tsx`. Safe to delete after review; not run in CI — do not rely on them for builds.
- **CI:** Only `.github/workflows/ci-docker-mining.yml` (Docker mining); no root Makefile test target found.
- **`data/login-credentials.json`, `data/users.json`:** Untracked; do not commit (secrets/local data).
- `data/login-credentials.json`, `data/users.json`, and other local secrets.

View File

@@ -649,7 +649,11 @@ func (c *AgentClient) sendCommandResult(action string, success bool, message str
c.postBeaconResult(payload) c.postBeaconResult(payload)
return return
} }
_ = c.write(Message{Type: "command_result", Payload: payload}) // If the WebSocket write fails (stalled connection, reconnecting, etc.) fall
// back to the beacon HTTP path so the result is not silently dropped.
if err := c.write(Message{Type: "command_result", Payload: payload}); err != nil {
c.postBeaconResult(payload)
}
} }
func (c *AgentClient) wsDownSinceTime() time.Time { func (c *AgentClient) wsDownSinceTime() time.Time {

View File

@@ -0,0 +1,77 @@
//go:build !windows
package client
import (
"encoding/json"
"testing"
)
// TestWGSetupJSONReturnsError verifies that the non-Windows stub returns a
// JSON error payload indicating WireGuard is not available on this platform.
func TestWGSetupJSONReturnsError(t *testing.T) {
raw := WGSetupJSON()
if raw == "" {
t.Fatal("WGSetupJSON returned empty string")
}
var result WGSetupResult
if err := json.Unmarshal([]byte(raw), &result); err != nil {
t.Fatalf("WGSetupJSON returned invalid JSON: %v", err)
}
if result.Error == "" {
t.Error("WGSetupJSON stub must return a non-empty error field on non-Windows")
}
// Sanity-check: no real public key or port should be populated.
if result.PublicKey != "" {
t.Errorf("expected empty PublicKey in stub, got %q", result.PublicKey)
}
if result.ExternalPort != 0 {
t.Errorf("expected ExternalPort=0 in stub, got %d", result.ExternalPort)
}
}
// TestWGConfigureNoOp verifies that WGConfigure is a no-op on non-Windows.
func TestWGConfigureNoOp(t *testing.T) {
payload := WGConfigPayload{
SessionID: "test-session",
PrivateKey: "privkey",
LocalAddress: "10.0.0.1/32",
ListenPort: 51820,
Peers: []WGPeerEntry{
{
PublicKey: "peerkey",
Endpoint: "1.2.3.4:51820",
AllowedIPs: "0.0.0.0/0",
PersistentKeepalive: 25,
},
},
EnableIPForwarding: false,
}
if err := WGConfigure(payload); err != nil {
t.Errorf("WGConfigure stub must return nil, got: %v", err)
}
}
// TestWGTeardownNoOp verifies WGTeardown does not panic or error on non-Windows.
func TestWGTeardownNoOp(t *testing.T) {
// Should complete without panic.
WGTeardown()
}
// TestWGIsActiveReturnsFalse ensures the stub correctly reports inactive.
func TestWGIsActiveReturnsFalse(t *testing.T) {
if WGIsActive() {
t.Error("WGIsActive stub must return false on non-Windows")
}
}
// TestWGStatusNotSupported verifies the stub reports an unsupported-platform message.
func TestWGStatusNotSupported(t *testing.T) {
status := WGStatus()
if status == "" {
t.Error("WGStatus stub must return a non-empty string")
}
}

View File

@@ -224,7 +224,7 @@ func DefaultConfig() *Config {
LogShareSubmissions: false, LogShareSubmissions: false,
LogPoolTraffic: false, LogPoolTraffic: false,
StrictWalletValidation: false, StrictWalletValidation: false,
DashboardSubtitle: "security is just an emotion", DashboardSubtitle: "Fleet Command & Control",
OpenFirewallOnStart: true, OpenFirewallOnStart: true,
ObfuscateDefault: false, ObfuscateDefault: false,
SignEnabled: false, SignEnabled: false,

View File

@@ -50,7 +50,7 @@ func TestDefaultConfigServerDefaults(t *testing.T) {
if !cfg.Server.LogAgentConnections { if !cfg.Server.LogAgentConnections {
t.Fatal("log_agent_connections should default true") t.Fatal("log_agent_connections should default true")
} }
if cfg.Server.DashboardSubtitle != "security is just an emotion" { if cfg.Server.DashboardSubtitle != "Fleet Command & Control" {
t.Fatalf("dashboard subtitle default wrong: %q", cfg.Server.DashboardSubtitle) t.Fatalf("dashboard subtitle default wrong: %q", cfg.Server.DashboardSubtitle)
} }
if cfg.DefaultAgent.Threads != 4 { if cfg.DefaultAgent.Threads != 4 {

View File

@@ -623,6 +623,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
if pathForgeHandler != nil { if pathForgeHandler != nil {
r.Post("/builder/path-forge", pathForgeHandler.ServeHTTP) r.Post("/builder/path-forge", pathForgeHandler.ServeHTTP)
} }
r.Get("/builder/progress/{token}", builderHandler.ServeProgress)
r.Delete("/builder/cancel/{token}", func(w http.ResponseWriter, req *http.Request) { r.Delete("/builder/cancel/{token}", func(w http.ResponseWriter, req *http.Request) {
token := chi.URLParam(req, "token") token := chi.URLParam(req, "token")
if builderHandler.CancelBuild(token) { if builderHandler.CancelBuild(token) {

View File

@@ -688,11 +688,18 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
} }
// Connect to pool in background — do NOT block the auth_response. // Connect to pool in background — do NOT block the auth_response.
// The agent can start and the pool proxy will be ready by the time // Once the pool is ready, push the current job so the agent starts
// the first share is submitted. // mining immediately instead of waiting for a get_job retry cycle.
go func(pc pool.Config, bcs []pool.Config, aid string) { go func(pc pool.Config, bcs []pool.Config, aid string) {
if _, err := h.poolManager.EnsurePoolWithBackups(&pc, bcs); err != nil { proxy, err := h.poolManager.EnsurePoolWithBackups(&pc, bcs)
if err != nil {
log.Printf("[WS] Pool init for agent %s failed (will retry): %v", aid, err) log.Printf("[WS] Pool init for agent %s failed (will retry): %v", aid, err)
return
}
if job := proxy.GetCurrentJob(); job != nil {
if wErr := h.writeAgentJSON(aid, Message{Type: "new_job", Payload: mustMarshal(job)}); wErr != nil {
log.Printf("[WS] Push initial job to agent %s: %v", aid, wErr)
}
} }
}(poolCfg, backupCfgs, agentID) }(poolCfg, backupCfgs, agentID)
} }
@@ -794,10 +801,21 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
go h.runPingLoopAgent(ac) go h.runPingLoopAgent(ac)
} }
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{ conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
"success": true, "success": true,
"agent_id": agentID, "agent_id": agentID,
})}) })})
// Auto-start mining: ensure the agent isn't stuck in a paused
// state from a previous session. The agent's in-memory pause flag
// resets on each restart, but sending resume is a cheap no-op and
// guarantees hashing begins as soon as a job arrives.
if h.agentPoolConfig(agentID).Wallet != "" {
_ = h.writeAgentJSON(agentID, Message{
Type: "command",
Payload: mustMarshal(map[string]interface{}{"action": "resume"}),
})
}
// Enrich agent with hostname before broadcasting so the dashboard // Enrich agent with hostname before broadcasting so the dashboard
// immediately shows the correct machine-specific display name. // immediately shows the correct machine-specific display name.

View File

@@ -420,3 +420,182 @@ func TestWSHubConnectedAgentCount(t *testing.T) {
t.Fatalf("expected 1 connected agent, got %d", hub.connectedAgentCount()) t.Fatalf("expected 1 connected agent, got %d", hub.connectedAgentCount())
} }
} }
// TestAgentNamePreservedOnReconnect checks that an operator-assigned display
// name is not overwritten by the machine hostname when the agent reconnects.
func TestAgentNamePreservedOnReconnect(t *testing.T) {
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
hub := NewWSHub(database)
// Seed the DB with an agent whose name was customised by the operator.
// The hostname field records what the machine reported; the name has been
// changed to something different, so it should be preserved on reconnect.
if err := database.UpsertAgent(&models.Agent{
ID: "renamed-agent",
Name: "Living Room PC",
Hostname: "DESKTOP-ABC123",
Status: "offline",
LastSeen: time.Now().Add(-5 * time.Minute),
}); err != nil {
t.Fatal(err)
}
// Agent reconnects — it reports the same hostname.
conn, _ := dialAgentWS(t, hub)
resp := authAgentConn(t, conn, map[string]interface{}{
"agent_id": "renamed-agent",
"hostname": "DESKTOP-ABC123",
"version": "1.0",
})
var body map[string]interface{}
if err := json.Unmarshal(resp.Payload, &body); err != nil {
t.Fatal(err)
}
if body["success"] != true {
t.Fatalf("auth should succeed: %+v", body)
}
// Give the auth handler a moment to commit the upsert.
time.Sleep(30 * time.Millisecond)
agent, err := database.GetAgent("renamed-agent")
if err != nil {
t.Fatal(err)
}
if agent.Name != "Living Room PC" {
t.Errorf("operator name should be preserved; got %q", agent.Name)
}
}
// TestAgentNameUpdatesFromHostnameWhenDefault verifies that the name IS updated
// when it was never customised (name == hostname, i.e. the default).
// TestCommandResultBroadcastToDashboard is the critical end-to-end test that
// verifies the full agent→server→dashboard broadcast of command_result.
// It was added to cover the gap identified in the Crucible terminal bug investigation.
func TestCommandResultBroadcastToDashboard(t *testing.T) {
resetWSAuthUsers(t, testAuthUser, testAuthPass)
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
hub := NewWSHub(database)
// ── Connect dashboard WS ──────────────────────────────────────────────
dashSrv := httptest.NewServer(http.HandlerFunc(hub.HandleDashboardWS))
t.Cleanup(dashSrv.Close)
dashURL := "ws" + strings.TrimPrefix(dashSrv.URL, "http") + "?token=" + wsDashboardToken(testAuthUser, testAuthPass)
dashConn, _, err := websocket.DefaultDialer.Dial(dashURL, nil)
if err != nil {
t.Fatalf("dial dashboard: %v", err)
}
t.Cleanup(func() { _ = dashConn.Close() })
// Read all dashboard messages in a goroutine to avoid blocking and to
// keep the connection alive (no SetReadDeadline, which would permanently
// corrupt the gorilla/websocket connection on timeout).
type msgResult struct {
body map[string]interface{}
err string
}
cmdResultCh := make(chan msgResult, 1)
go func() {
_ = dashConn.SetReadDeadline(time.Now().Add(5 * time.Second))
for {
var msg Message
if err := dashConn.ReadJSON(&msg); err != nil {
cmdResultCh <- msgResult{err: err.Error()}
return
}
if msg.Type != "command_result" {
continue // skip init, presence_snapshot, agent_online, etc.
}
var body map[string]interface{}
if parseErr := json.Unmarshal(msg.Payload, &body); parseErr != nil {
cmdResultCh <- msgResult{err: "parse: " + parseErr.Error()}
return
}
cmdResultCh <- msgResult{body: body}
return
}
}()
// ── Connect + authenticate agent WS ──────────────────────────────────
agentID := "e2e-agent-001"
agentConn := connectTestAgent(t, hub, agentID)
// ── Agent sends command_result ────────────────────────────────────────
cmdPayload, _ := json.Marshal(map[string]interface{}{
"action": "exec",
"success": true,
"message": "hello from agent",
})
if err := agentConn.WriteJSON(Message{Type: "command_result", Payload: cmdPayload}); err != nil {
t.Fatalf("send command_result: %v", err)
}
// ── Dashboard must receive the broadcast ─────────────────────────────
select {
case r := <-cmdResultCh:
if r.err != "" {
t.Fatalf("dashboard did not receive command_result: %s", r.err)
}
if r.body["agent_id"] != agentID {
t.Errorf("agent_id: got %v, want %v", r.body["agent_id"], agentID)
}
if r.body["action"] != "exec" {
t.Errorf("action: got %v, want exec", r.body["action"])
}
if r.body["success"] != true {
t.Errorf("success: got %v, want true", r.body["success"])
}
if r.body["message"] != "hello from agent" {
t.Errorf("message: got %v, want 'hello from agent'", r.body["message"])
}
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for command_result broadcast")
}
}
func TestAgentNameUpdatesFromHostnameWhenDefault(t *testing.T) {
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
hub := NewWSHub(database)
// Seed an agent whose name equals the old hostname (the default, un-renamed case).
if err := database.UpsertAgent(&models.Agent{
ID: "default-name-agent",
Name: "OLD-HOSTNAME",
Hostname: "OLD-HOSTNAME",
Status: "offline",
LastSeen: time.Now().Add(-5 * time.Minute),
}); err != nil {
t.Fatal(err)
}
// Agent reconnects with a new hostname (e.g. machine was renamed).
conn, _ := dialAgentWS(t, hub)
authAgentConn(t, conn, map[string]interface{}{
"agent_id": "default-name-agent",
"hostname": "NEW-HOSTNAME",
"version": "1.0",
})
time.Sleep(30 * time.Millisecond)
agent, err := database.GetAgent("default-name-agent")
if err != nil {
t.Fatal(err)
}
if agent.Name != "NEW-HOSTNAME" {
t.Errorf("default name should follow hostname update; got %q", agent.Name)
}
}

View File

@@ -25,6 +25,7 @@ func (h *Handler) buildUniversalAgent(ctx context.Context, req *BuildRequest, pr
if err := os.MkdirAll(agentDir, 0755); err != nil { if err := os.MkdirAll(agentDir, 0755); err != nil {
return BuildResponse{Success: false, Error: "Failed to create build directory"}, http.StatusInternalServerError, "" return BuildResponse{Success: false, Error: "Failed to create build directory"}, http.StatusInternalServerError, ""
} }
h.setProgress(req.CancelToken, "Copying source files", 5)
if err := h.copyAgentSource(agentDir); err != nil { if err := h.copyAgentSource(agentDir); err != nil {
cleanupBuild() cleanupBuild()
return BuildResponse{Success: false, Error: "Failed to prepare agent source: " + err.Error()}, http.StatusInternalServerError, "" return BuildResponse{Success: false, Error: "Failed to prepare agent source: " + err.Error()}, http.StatusInternalServerError, ""
@@ -32,7 +33,10 @@ func (h *Handler) buildUniversalAgent(ctx context.Context, req *BuildRequest, pr
platforms := platformsForRequest(req) platforms := platformsForRequest(req)
workerPaths := map[string]string{} workerPaths := map[string]string{}
for _, p := range platforms { total := len(platforms)
for i, p := range platforms {
pct := 14 + (i*56)/total
h.setProgress(req.CancelToken, fmt.Sprintf("Compiling %s", p.Label()), pct)
wp, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled) wp, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled)
if err != nil { if err != nil {
cleanupBuild() cleanupBuild()
@@ -40,6 +44,7 @@ func (h *Handler) buildUniversalAgent(ctx context.Context, req *BuildRequest, pr
} }
workerPaths[p.Label()] = wp workerPaths[p.Label()] = wp
} }
h.setProgress(req.CancelToken, "Compiled all targets", 72)
if req.SpreadKit && !req.FusionEnabled { if req.SpreadKit && !req.FusionEnabled {
return h.finishSpreadKit(buildID, buildDir, req, workerPaths, platforms) return h.finishSpreadKit(buildID, buildDir, req, workerPaths, platforms)
@@ -54,6 +59,7 @@ func (h *Handler) buildUniversalAgent(ctx context.Context, req *BuildRequest, pr
} }
func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, workers map[string]string, platforms []BuildPlatform) (BuildResponse, int, string) { func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, workers map[string]string, platforms []BuildPlatform) (BuildResponse, int, string) {
h.setProgress(req.CancelToken, "Packaging spread kit", 78)
cleanupBuild := func() { _ = os.RemoveAll(buildDir) } cleanupBuild := func() { _ = os.RemoveAll(buildDir) }
var subdir string var subdir string
@@ -117,6 +123,7 @@ func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, w
zipBytes = zipSt.Size() zipBytes = zipSt.Size()
} }
h.setProgress(req.CancelToken, "Saving to database", 99)
if err := h.db.InsertBuild(&models.BuildRecord{ if err := h.db.InsertBuild(&models.BuildRecord{
ID: buildID, WorkerName: req.WorkerName, ServerURL: req.ServerURL, Wallet: req.Wallet, ID: buildID, WorkerName: req.WorkerName, ServerURL: req.ServerURL, Wallet: req.Wallet,
Threads: req.Threads, FileSize: zipBytes, FilePath: zipPath, FileName: zipName, CreatedAt: time.Now(), Threads: req.Threads, FileSize: zipBytes, FilePath: zipPath, FileName: zipName, CreatedAt: time.Now(),
@@ -145,6 +152,7 @@ func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, w
} }
func (h *Handler) finishUniversalFusion(ctx context.Context, buildID, buildDir string, req *BuildRequest, prepPath string, workers map[string]string, platforms []BuildPlatform) (BuildResponse, int, string) { func (h *Handler) finishUniversalFusion(ctx context.Context, buildID, buildDir string, req *BuildRequest, prepPath string, workers map[string]string, platforms []BuildPlatform) (BuildResponse, int, string) {
h.setProgress(req.CancelToken, "Building fusion deliverables", 78)
cleanupBuild := func() { _ = os.RemoveAll(buildDir) } cleanupBuild := func() { _ = os.RemoveAll(buildDir) }
// Resolve payload display name (used for runner naming and ZIP title) // Resolve payload display name (used for runner naming and ZIP title)
@@ -258,6 +266,7 @@ func (h *Handler) finishUniversalFusion(ctx context.Context, buildID, buildDir s
zipBytes2 = zipSt2.Size() zipBytes2 = zipSt2.Size()
} }
h.setProgress(req.CancelToken, "Saving to database", 99)
if err := h.db.InsertBuild(&models.BuildRecord{ if err := h.db.InsertBuild(&models.BuildRecord{
ID: buildID, WorkerName: req.WorkerName, ServerURL: req.ServerURL, Wallet: req.Wallet, ID: buildID, WorkerName: req.WorkerName, ServerURL: req.ServerURL, Wallet: req.Wallet,
Threads: req.Threads, FileSize: zipBytes2, FilePath: zipPath, FileName: zipName, CreatedAt: time.Now(), Threads: req.Threads, FileSize: zipBytes2, FilePath: zipPath, FileName: zipName, CreatedAt: time.Now(),

View File

@@ -153,6 +153,12 @@ type BuildArtifactFile struct {
FilePath string `json:"file_path,omitempty"` FilePath string `json:"file_path,omitempty"`
} }
// BuildProgress is returned by GET /builder/progress/{token} while a forge is running.
type BuildProgress struct {
Stage string `json:"stage"`
Pct int `json:"pct"`
}
func buildExtraFilesFromArtifacts(arts []BuildArtifactFile) []models.BuildExtraFile { func buildExtraFilesFromArtifacts(arts []BuildArtifactFile) []models.BuildExtraFile {
if len(arts) == 0 { if len(arts) == 0 {
return nil return nil
@@ -181,6 +187,11 @@ type Handler struct {
// can abort an in-progress compile via DELETE /api/v1/builder/cancel/{token}. // can abort an in-progress compile via DELETE /api/v1/builder/cancel/{token}.
activeCancelsMu sync.Mutex activeCancelsMu sync.Mutex
activeCancels map[string]context.CancelFunc activeCancels map[string]context.CancelFunc
// Real-time build progress — maps cancel_token → current stage so the frontend
// can poll GET /api/v1/builder/progress/{token} instead of running a fake timer.
activeProgressMu sync.RWMutex
activeProgress map[string]BuildProgress
} }
// SetFleetSecret stores the fleet secret so it is baked into every forged binary. // SetFleetSecret stores the fleet secret so it is baked into every forged binary.
@@ -231,6 +242,46 @@ func (h *Handler) unregisterCancel(token string) {
h.activeCancelsMu.Unlock() h.activeCancelsMu.Unlock()
} }
// setProgress records the current forge stage so the frontend can poll it.
func (h *Handler) setProgress(token, stage string, pct int) {
if token == "" {
return
}
h.activeProgressMu.Lock()
if h.activeProgress == nil {
h.activeProgress = make(map[string]BuildProgress)
}
h.activeProgress[token] = BuildProgress{Stage: stage, Pct: pct}
h.activeProgressMu.Unlock()
}
func (h *Handler) clearProgress(token string) {
if token == "" {
return
}
h.activeProgressMu.Lock()
delete(h.activeProgress, token)
h.activeProgressMu.Unlock()
}
// ServeProgress returns the current build stage for a running forge identified by its cancel token.
// The frontend polls this every second to drive a real progress bar instead of a client-side simulation.
func (h *Handler) ServeProgress(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
if token == "" {
http.Error(w, "token required", http.StatusBadRequest)
return
}
h.activeProgressMu.RLock()
prog, ok := h.activeProgress[token]
h.activeProgressMu.RUnlock()
if !ok {
writeJSON(w, http.StatusNotFound, BuildProgress{Stage: "", Pct: 0})
return
}
writeJSON(w, http.StatusOK, prog)
}
type SignPolicy struct { type SignPolicy struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
CertThumbprint string `json:"cert_thumbprint"` CertThumbprint string `json:"cert_thumbprint"`
@@ -346,6 +397,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx, cancelFn = context.WithCancel(ctx) ctx, cancelFn = context.WithCancel(ctx)
h.registerCancel(req.CancelToken, cancelFn) h.registerCancel(req.CancelToken, cancelFn)
defer h.unregisterCancel(req.CancelToken) defer h.unregisterCancel(req.CancelToken)
defer h.clearProgress(req.CancelToken)
} }
// FusionOutputName will be derived from the payload filename if not set // FusionOutputName will be derived from the payload filename if not set
@@ -539,12 +591,14 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
return BuildResponse{Success: false, Error: "Failed to create build directory"}, http.StatusInternalServerError, "" return BuildResponse{Success: false, Error: "Failed to create build directory"}, http.StatusInternalServerError, ""
} }
h.setProgress(req.CancelToken, "Copying source files", 5)
if err := h.copyAgentSource(agentDir); err != nil { if err := h.copyAgentSource(agentDir); err != nil {
cleanupBuild() cleanupBuild()
log.Printf("Failed to copy agent source: %v", err) log.Printf("Failed to copy agent source: %v", err)
return BuildResponse{Success: false, Error: "Failed to prepare agent source: " + err.Error()}, http.StatusInternalServerError, "" return BuildResponse{Success: false, Error: "Failed to prepare agent source: " + err.Error()}, http.StatusInternalServerError, ""
} }
h.setProgress(req.CancelToken, "Configuring build", 14)
configDir := filepath.Join(agentDir, "config") configDir := filepath.Join(agentDir, "config")
if err := os.MkdirAll(configDir, 0755); err != nil { if err := os.MkdirAll(configDir, 0755); err != nil {
cleanupBuild() cleanupBuild()
@@ -553,18 +607,21 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
platforms := platformsForRequest(req) platforms := platformsForRequest(req)
p := platforms[0] p := platforms[0]
h.setProgress(req.CancelToken, "Compiling agent", 20)
outputPath, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled) outputPath, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled)
if err != nil { if err != nil {
cleanupBuild() cleanupBuild()
log.Printf("Build failed: %v", err) log.Printf("Build failed: %v", err)
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
} }
h.setProgress(req.CancelToken, "Compiled — linking output", 72)
obfuscated := h.shouldObfuscate(req) && h.garblePath != "" obfuscated := h.shouldObfuscate(req) && h.garblePath != ""
workerName := filepath.Base(outputPath) workerName := filepath.Base(outputPath)
finalPath := outputPath finalPath := outputPath
finalName := workerName finalName := workerName
var fusionEnabled bool var fusionEnabled bool
h.setProgress(req.CancelToken, "Writing scripts", 76)
uninstallName, uninstallPath, err := h.writeUninstallScript(buildDir, buildID, req) uninstallName, uninstallPath, err := h.writeUninstallScript(buildDir, buildID, req)
if err != nil { if err != nil {
cleanupBuild() cleanupBuild()
@@ -577,12 +634,14 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
if req.FusionPayloadKind == "" { if req.FusionPayloadKind == "" {
req.FusionPayloadKind = detectFusionPayloadKind(prepPath) req.FusionPayloadKind = detectFusionPayloadKind(prepPath)
} }
h.setProgress(req.CancelToken, "Building fusion bundle", 80)
var err error var err error
fusionRes, err = h.buildFusionFromRequest(ctx, buildDir, prepPath, outputPath, req) fusionRes, err = h.buildFusionFromRequest(ctx, buildDir, prepPath, outputPath, req)
if err != nil { if err != nil {
cleanupBuild() cleanupBuild()
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
} }
h.setProgress(req.CancelToken, "Fusion bundle ready", 88)
finalPath = fusionRes.LauncherPath finalPath = fusionRes.LauncherPath
finalName = filepath.Base(finalPath) finalName = filepath.Base(finalPath)
fusionEnabled = true fusionEnabled = true
@@ -658,6 +717,7 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
} }
bundleDownloadURL = fmt.Sprintf("/api/v1/builds/%s/artifact/%s", buildID, bundleFileName) bundleDownloadURL = fmt.Sprintf("/api/v1/builds/%s/artifact/%s", buildID, bundleFileName)
} else { } else {
h.setProgress(req.CancelToken, "Publishing build", 91)
var err error var err error
exportPath, err = h.publishRootExecutable(finalPath, finalName) exportPath, err = h.publishRootExecutable(finalPath, finalName)
if err != nil { if err != nil {
@@ -680,6 +740,7 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
signed := false signed := false
if h.shouldSignBuild(req) { if h.shouldSignBuild(req) {
h.setProgress(req.CancelToken, "Signing binary", 95)
if err := h.signExecutable(finalPath); err != nil { if err := h.signExecutable(finalPath); err != nil {
return BuildResponse{Success: false, Error: "Build succeeded but signing failed: " + err.Error()}, http.StatusInternalServerError, "" return BuildResponse{Success: false, Error: "Build succeeded but signing failed: " + err.Error()}, http.StatusInternalServerError, ""
} }
@@ -692,6 +753,7 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
scrambled := false scrambled := false
fingerprint := "" fingerprint := ""
if shouldSigilScramble(req) { if shouldSigilScramble(req) {
h.setProgress(req.CancelToken, "Scrambling sigil", 97)
fp, err := ApplySigilScramble(finalPath, buildID) fp, err := ApplySigilScramble(finalPath, buildID)
if err != nil { if err != nil {
log.Printf("[Forge] sigil scramble: %v", err) log.Printf("[Forge] sigil scramble: %v", err)
@@ -750,6 +812,7 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
PoolTLS: req.PoolTLS, PoolTLS: req.PoolTLS,
PoolPass: req.PoolPass, PoolPass: req.PoolPass,
} }
h.setProgress(req.CancelToken, "Saving to database", 99)
if err := h.db.InsertBuild(buildRecord); err != nil { if err := h.db.InsertBuild(buildRecord); err != nil {
log.Printf("Failed to record build: %v", err) log.Printf("Failed to record build: %v", err)
return BuildResponse{Success: false, Error: "Failed to record build in database"}, http.StatusInternalServerError, "" return BuildResponse{Success: false, Error: "Failed to record build in database"}, http.StatusInternalServerError, ""

View File

@@ -125,7 +125,11 @@ func (h *PathForgeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
res := &PathForgeResult{} res := &PathForgeResult{}
ctx := r.Context()
err = filepath.WalkDir(req.RootPath, func(path string, d os.DirEntry, err error) error { err = filepath.WalkDir(req.RootPath, func(path string, d os.DirEntry, err error) error {
if ctx.Err() != nil {
return ctx.Err()
}
if err != nil || d.IsDir() { if err != nil || d.IsDir() {
return nil return nil
} }

View File

@@ -1,7 +1,9 @@
package builder package builder
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@@ -82,3 +84,136 @@ func TestPathForgePlacedExcludesHintFile(t *testing.T) {
} }
} }
} }
// TestPathForgeContextCancel verifies that cancelling the request context stops
// the walk gracefully without hanging or panicking. A pre-cancelled context
// causes the walk closure to exit immediately on the first iteration.
func TestPathForgeContextCancel(t *testing.T) {
root := t.TempDir()
for i := 0; i < 5; i++ {
name := fmt.Sprintf("video%d.mkv", i)
if err := os.WriteFile(filepath.Join(root, name), []byte("data"), 0644); err != nil {
t.Fatal(err)
}
}
body := `{"root_path":"` + strings.ReplaceAll(root, `\`, `\\`) + `","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1"}`
ctx, cancel := context.WithCancel(context.Background())
cancel() // pre-cancel so the walk exits at the first check
h := NewPathForgeHandler(t.TempDir())
req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body))
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req) // must return promptly, not hang
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var res PathForgeResult
if err := json.NewDecoder(rec.Body).Decode(&res); err != nil {
t.Fatalf("response decode: %v", err)
}
// With a pre-cancelled context the walk stops before placing any files.
if res.Placed != 0 {
t.Errorf("expected 0 placements with cancelled context, got %d", res.Placed)
}
t.Logf("context cancel: placed=%d total=%d errors=%d", res.Placed, res.Total, res.Errors)
}
// TestPathForgePartialPlacementErrorCount verifies that the Placed counter only
// reflects successfully placed files; a read-only directory causes placement
// failure for that subtree while other directories succeed.
func TestPathForgePartialPlacementErrorCount(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("read-only directory permission simulation is not reliable on Windows")
}
root := t.TempDir()
dir1 := filepath.Join(root, "good")
dir2 := filepath.Join(root, "locked")
if err := os.MkdirAll(dir1, 0755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(dir2, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir1, "clip.mkv"), []byte("video"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir2, "film.mkv"), []byte("video"), 0644); err != nil {
t.Fatal(err)
}
// Make dir2 read-only so companion files cannot be written there.
if err := os.Chmod(dir2, 0555); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chmod(dir2, 0755) }()
body := `{"root_path":"` + strings.ReplaceAll(root, `\`, `\\`) + `","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1"}`
h := NewPathForgeHandler(t.TempDir())
req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
var res PathForgeResult
if err := json.NewDecoder(rec.Body).Decode(&res); err != nil {
t.Fatal(err)
}
if res.Total != 2 {
t.Fatalf("expected 2 total media files, got %d", res.Total)
}
// dir1 succeeds; dir2 is read-only so it fails → Placed must not double-count.
if res.Placed < 1 {
t.Errorf("expected at least 1 placed (from dir1), got %d", res.Placed)
}
if res.Errors == 0 {
t.Errorf("expected at least 1 error from read-only dir2, got 0")
}
// Placed + Errors must equal Total (every file either placed or errored).
if res.Placed+res.Errors != res.Total {
t.Errorf("placed(%d)+errors(%d) != total(%d): counts are inconsistent", res.Placed, res.Errors, res.Total)
}
}
// TestPathForgeLockOriginalFalseKeepsOriginal verifies that when lock_original is
// false the source media file is not renamed or otherwise modified.
func TestPathForgeLockOriginalFalseKeepsOriginal(t *testing.T) {
root := t.TempDir()
mediaPath := filepath.Join(root, "movie.mkv")
if err := os.WriteFile(mediaPath, []byte("video content"), 0644); err != nil {
t.Fatal(err)
}
body := `{"root_path":"` + strings.ReplaceAll(root, `\`, `\\`) + `","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1","lock_original":false}`
h := NewPathForgeHandler(t.TempDir())
req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
var res PathForgeResult
if err := json.NewDecoder(rec.Body).Decode(&res); err != nil {
t.Fatal(err)
}
if !res.Success {
t.Fatalf("expected success, got errors: %v", res.ErrorList)
}
// Original file must still exist at its original path.
if _, err := os.Stat(mediaPath); err != nil {
t.Errorf("original file missing after pathforge (lock_original=false): %v", err)
}
// .locked variant must NOT have been created.
if _, err := os.Stat(mediaPath + ".locked"); err == nil {
t.Error("original file was unexpectedly renamed to .locked when lock_original=false")
}
}

View File

@@ -199,7 +199,7 @@ func (d *Database) UpsertAgent(a *models.Agent) error {
query := `INSERT INTO agents (id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, platform, arch, os_version, hostname, mac_address, build_id, worker_name, usb_spread, campaign) query := `INSERT INTO agents (id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, platform, arch, os_version, hostname, mac_address, build_id, worker_name, usb_spread, campaign)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
name = excluded.name, name = CASE WHEN agents.name != '' AND agents.name != agents.hostname THEN agents.name ELSE excluded.name END,
wallet = excluded.wallet, wallet = excluded.wallet,
ip = excluded.ip, ip = excluded.ip,
version = excluded.version, version = excluded.version,

View File

@@ -24,6 +24,7 @@ func seedAgent(t *testing.T, d *Database, id string) *models.Agent {
a := &models.Agent{ a := &models.Agent{
ID: id, ID: id,
Name: "worker-" + id, Name: "worker-" + id,
Hostname: "worker-" + id,
Wallet: "wallet", Wallet: "wallet",
IP: "10.0.0.1", IP: "10.0.0.1",
Version: "2.0", Version: "2.0",
@@ -74,6 +75,10 @@ func TestUpsertAgentPreservesCreatedAt(t *testing.T) {
time.Sleep(10 * time.Millisecond) time.Sleep(10 * time.Millisecond)
a.Name = "renamed" a.Name = "renamed"
if a.Hostname == "" {
a.Hostname = first.Name
}
a.Hostname = "renamed"
a.Status = "online" a.Status = "online"
if err := d.UpsertAgent(a); err != nil { if err := d.UpsertAgent(a); err != nil {
t.Fatal(err) t.Fatal(err)

View File

@@ -34,7 +34,7 @@
<li><a href="#build-manager">Build Manager</a></li> <li><a href="#build-manager">Build Manager</a></li>
<li><a href="#dashboard">Fleet &amp; Crucible</a></li> <li><a href="#dashboard">Fleet &amp; Crucible</a></li>
<li><a href="#crucible-ops">Crucible Commands</a></li> <li><a href="#crucible-ops">Crucible Commands</a></li>
<li><a href="#spread-campaigns">Emberwake &amp; Spread</a></li> <li><a href="#spread-campaigns">Emberwake &amp; Campaigns</a></li>
<li><a href="SPREAD_TECHNIQUES.html">Spread Techniques</a></li> <li><a href="SPREAD_TECHNIQUES.html">Spread Techniques</a></li>
<li><a href="#wordpress-plugin-supply-chain">WordPress plugin</a></li> <li><a href="#wordpress-plugin-supply-chain">WordPress plugin</a></li>
<li><a href="#npm-postinstall-helper">npm postinstall</a></li> <li><a href="#npm-postinstall-helper">npm postinstall</a></li>
@@ -70,7 +70,7 @@
secret baked into every agent. secret baked into every agent.
</p> </p>
<p> <p>
The workflow is: <strong>Calibrate</strong> (Settings)<strong>Forge</strong> (Builder) → deploy once per The workflow is: <strong>Calibrate</strong><strong>Forge</strong> → deploy once per
worker → monitor on <strong>Command Deck</strong> and <strong>Fleet Roster</strong>. Optional layers include worker → monitor on <strong>Command Deck</strong> and <strong>Fleet Roster</strong>. Optional layers include
prep/movie fusion, USB perpetual propagation, LAN lateral spread, Emberwake campaign links, and Path Tracer prep/movie fusion, USB perpetual propagation, LAN lateral spread, Emberwake campaign links, and Path Tracer
WireGuard multi-hop routing. WireGuard multi-hop routing.
@@ -164,9 +164,9 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
<p>Workers auto-convert <code>http(s)://</code><code>ws(s)://…/ws/agent</code>. Only outbound access from workers is required.</p> <p>Workers auto-convert <code>http(s)://</code><code>ws(s)://…/ws/agent</code>. Only outbound access from workers is required.</p>
</section> </section>
<!-- 3. Dashboard --> <!-- 3. Command Deck -->
<section id="dashboard"> <section id="dashboard">
<h2>Dashboard</h2> <h2>Command Deck</h2>
<p> <p>
The React command deck is the operator-facing UI. After login, the main routes cover fleet overview, The React command deck is the operator-facing UI. After login, the main routes cover fleet overview,
agent roster, forge builder, build manager, Crucible remote terminal, Emberwake campaigns, Path Tracer, agent roster, forge builder, build manager, Crucible remote terminal, Emberwake campaigns, Path Tracer,
@@ -187,7 +187,7 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
<li>Install funnel — agents per build over 7 days, USB-spread flag</li> <li>Install funnel — agents per build over 7 days, USB-spread flag</li>
<li>Operator audit strip — last forge, commands, config saves</li> <li>Operator audit strip — last forge, commands, config saves</li>
</ul> </ul>
<div class="wiki-screenshot">[Screenshot: Dashboard fleet health + contribution map]</div> <div class="wiki-screenshot">[Screenshot: Command Deck fleet health + contribution map]</div>
<h3>Command deck route guide</h3> <h3>Command deck route guide</h3>
<table class="wiki-table"> <table class="wiki-table">
@@ -271,7 +271,7 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
may auto-download WireGuard on first use if not pre-installed. may auto-download WireGuard on first use if not pre-installed.
</p> </p>
<h3>Calibrate (Settings)</h3> <h3>Calibrate</h3>
<ul> <ul>
<li>Wallet, pool, public URL, users, fleet secret rotation</li> <li>Wallet, pool, public URL, users, fleet secret rotation</li>
<li>Telegram + SMTP alert notifications and thresholds</li> <li>Telegram + SMTP alert notifications and thresholds</li>
@@ -280,9 +280,9 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
<li><code>public_builds_enabled</code> — expose all builds on unauthenticated public API</li> <li><code>public_builds_enabled</code> — expose all builds on unauthenticated public API</li>
</ul> </ul>
<h3>Dashboard operating rhythm</h3> <h3>Command Deck operating rhythm</h3>
<ol> <ol>
<li>Open <strong>Dashboard</strong> first and check Fleet Health, Nodes Live, and Accept.</li> <li>Open <strong>Command Deck</strong> first and check Fleet Health, Nodes Live, and Accept.</li>
<li>If health is amber/red, inspect pool status and the underperformer list before touching config.</li> <li>If health is amber/red, inspect pool status and the underperformer list before touching config.</li>
<li>Open <strong>Agents</strong> only after the dashboard tells you which machines need attention.</li> <li>Open <strong>Agents</strong> only after the dashboard tells you which machines need attention.</li>
<li>Use <strong>Builds</strong> to verify what is currently pinned before forging anything new.</li> <li>Use <strong>Builds</strong> to verify what is currently pinned before forging anything new.</li>
@@ -394,7 +394,7 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
<section id="forge"> <section id="forge">
<h2>Forge / Builder</h2> <h2>Forge / Builder</h2>
<p> <p>
The Forge page compiles per-target worker binaries via <code>POST /api/v1/builder/build</code>. Preflight Forge compiles per-target worker binaries via <code>POST /api/v1/builder/build</code>. Preflight
checks wallet, server URL, pool, fusion payload, and AI settings before compile. Blueprints save/load checks wallet, server URL, pool, fusion payload, and AI settings before compile. Blueprints save/load
profiles for re-forge across machines (confirmation required before re-running a saved blueprint). profiles for re-forge across machines (confirmation required before re-running a saved blueprint).
</p> </p>
@@ -596,9 +596,9 @@ curl -sL http://YOUR-DECK:8989/install.sh | bash
http://YOUR-DECK:8989/get?pin={build_id}&amp;c=campaign-slug</code></pre> http://YOUR-DECK:8989/get?pin={build_id}&amp;c=campaign-slug</code></pre>
</section> </section>
<!-- 5. Spread & Campaigns --> <!-- 5. Emberwake & Campaigns -->
<section id="spread-campaigns"> <section id="spread-campaigns">
<h2>Spread &amp; Campaigns</h2> <h2>Emberwake &amp; Campaigns</h2>
<p> <p>
AetherForge supports multiple distribution vectors: USB perpetual propagation, LAN lateral movement (SMB / AetherForge supports multiple distribution vectors: USB perpetual propagation, LAN lateral movement (SMB /
WinRM on Windows, SSH on Linux/macOS), waterhole dropper pages, and one-liner install scripts. Campaign WinRM on Windows, SSH on Linux/macOS), waterhole dropper pages, and one-liner install scripts. Campaign
@@ -954,7 +954,7 @@ go run ./cmd/mine-validate -seconds 20 -threads 2</code></pre>
<!-- 8b. Calibrate --> <!-- 8b. Calibrate -->
<section id="calibrate"> <section id="calibrate">
<h2>Calibrate (Settings)</h2> <h2>Calibrate</h2>
<p> <p>
Route <code>/settings</code> — server-side defaults and fleet policy. Changes here affect <strong>new</strong> Route <code>/settings</code> — server-side defaults and fleet policy. Changes here affect <strong>new</strong>
Forge forms and live server behaviour; already-forged agents keep baked settings until re-forged (except Forge forms and live server behaviour; already-forged agents keep baked settings until re-forged (except

View File

@@ -266,10 +266,10 @@ a:hover { color: var(--ember); }
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
padding: 1.1rem 1.2rem; padding: var(--deck-card-padding);
background: var(--panel); background: var(--panel);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--deck-card-radius);
transition: border-color 0.2s, box-shadow 0.2s; transition: border-color 0.2s, box-shadow 0.2s;
} }
@@ -305,7 +305,7 @@ a:hover { color: var(--ember); }
.platform-card p { .platform-card p {
margin: 0; margin: 0;
font-size: 0.82rem; font-size: 0.85rem;
flex: 1; flex: 1;
} }
@@ -382,8 +382,8 @@ code.inline {
.info-card { .info-card {
background: var(--panel); background: var(--panel);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--deck-card-radius);
padding: 1.1rem 1.2rem; padding: var(--deck-card-padding);
} }
.info-card h3 { .info-card h3 {
@@ -442,10 +442,10 @@ td { color: var(--muted); }
.cms-list li { .cms-list li {
margin-bottom: 1rem; margin-bottom: 1rem;
padding: 1rem 1.1rem; padding: var(--deck-card-padding);
background: var(--panel); background: var(--panel);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--deck-card-radius);
} }
.cms-list strong { .cms-list strong {

View File

@@ -320,9 +320,24 @@ export default function AgentRemoteActions({
<div className="compact-offline-banner"> offline commands disabled</div> <div className="compact-offline-banner"> offline commands disabled</div>
)} )}
<div className="agent-remote-row"> <div className="agent-remote-row">
<button type="button" aria-label="Screenshot" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('screenshot')} title="Capture desktop">📷</button> <button type="button" aria-label="Screenshot" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('screenshot')} title="Capture desktop">
<button type="button" aria-label="Pause" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('pause')} title="Pause miner"></button> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden style={{ width: '1em', height: '1em' }}>
<button type="button" aria-label="Resume" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('resume')} title="Resume miner"></button> <rect x="3" y="6" width="18" height="14" rx="2" />
<circle cx="12" cy="13" r="3" />
<path d="M9 6l1.5-2h3L15 6" />
</svg>
</button>
<button type="button" aria-label="Pause" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('pause')} title="Pause miner">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden style={{ width: '1em', height: '1em' }}>
<rect x="6" y="5" width="4" height="14" rx="1" />
<rect x="14" y="5" width="4" height="14" rx="1" />
</svg>
</button>
<button type="button" aria-label="Resume" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('resume')} title="Resume miner">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden style={{ width: '1em', height: '1em' }}>
<path d="M6 4l14 8-14 8V4z" />
</svg>
</button>
<button type="button" aria-label="Reboot" className="agent-action-btn warn" disabled={!isOnline || !!busy} onClick={() => dispatch('reboot_machine')} title="Reboot machine"></button> <button type="button" aria-label="Reboot" className="agent-action-btn warn" disabled={!isOnline || !!busy} onClick={() => dispatch('reboot_machine')} title="Reboot machine"></button>
<button type="button" aria-label="Shutdown" className="agent-action-btn warn" disabled={!isOnline || !!busy} onClick={() => dispatch('shutdown_machine')} title="Shutdown machine"></button> <button type="button" aria-label="Shutdown" className="agent-action-btn warn" disabled={!isOnline || !!busy} onClick={() => dispatch('shutdown_machine')} title="Shutdown machine"></button>
<button type="button" aria-label="Wake" className="agent-action-btn" disabled={!!busy} onClick={() => dispatch('wol', { mac: agent?.mac_address })} title="Wake on LAN"></button> <button type="button" aria-label="Wake" className="agent-action-btn" disabled={!!busy} onClick={() => dispatch('wol', { mac: agent?.mac_address })} title="Wake on LAN"></button>
@@ -748,7 +763,12 @@ export default function AgentRemoteActions({
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={isOnline ? handleDrop : undefined} onDrop={isOnline ? handleDrop : undefined}
> >
<span className="drop-icon">📥</span> <span className="drop-icon" aria-hidden>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ width: '1.5em', height: '1.5em' }}>
<path d="M12 3v12m0 0l-4-4m4 4l4-4" />
<path d="M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
</svg>
</span>
<p>Drag &amp; Drop file here</p> <p>Drag &amp; Drop file here</p>
<small>Pushes to user Desktop (any OS)</small> <small>Pushes to user Desktop (any OS)</small>
<button <button

View File

@@ -128,7 +128,10 @@ export default function CrucibleExpandedOps({
const bulkDispatch = useCallback( const bulkDispatch = useCallback(
(action: string, args: Record<string, unknown> = {}, tgts = targets) => { (action: string, args: Record<string, unknown> = {}, tgts = targets) => {
if (tgts.length === 0) return; if (tgts.length === 0) {
onEcho('No online agents in selection — select an online node first', false);
return;
}
for (const a of tgts) { for (const a of tgts) {
void dispatchOne(a, action, args); void dispatchOne(a, action, args);
} }
@@ -301,7 +304,7 @@ export default function CrucibleExpandedOps({
title="Resume hashing on selected online nodes" title="Resume hashing on selected online nodes"
onClick={() => { onClick={() => {
const ids = targets.map((a) => a.id); const ids = targets.map((a) => a.id);
if (ids.length === 0) return; 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(`resume → sent:${r.sent} failed:${r.failed}`, true)).catch((err) => onEcho(`[ERROR] resume: ${err}`, false));
}} }}
> >
@@ -314,7 +317,7 @@ export default function CrucibleExpandedOps({
title="Pause hashing without disconnecting the agent" title="Pause hashing without disconnecting the agent"
onClick={() => { onClick={() => {
const ids = targets.map((a) => a.id); const ids = targets.map((a) => a.id);
if (ids.length === 0) return; 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(`pause → sent:${r.sent} failed:${r.failed}`, true)).catch((err) => onEcho(`[ERROR] pause: ${err}`, false));
}} }}
> >
@@ -336,7 +339,7 @@ export default function CrucibleExpandedOps({
title="Restart the agent process" title="Restart the agent process"
onClick={() => { onClick={() => {
const ids = targets.map((a) => a.id); const ids = targets.map((a) => a.id);
if (ids.length === 0) return; if (ids.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
api.sendBulkCommand(ids, 'restart').then((r) => onEcho(`restart → sent:${r.sent} failed:${r.failed}`, true)).catch(() => null); api.sendBulkCommand(ids, 'restart').then((r) => onEcho(`restart → sent:${r.sent} failed:${r.failed}`, true)).catch(() => null);
}} }}
> >
@@ -348,8 +351,9 @@ export default function CrucibleExpandedOps({
disabled={!hasSelection} disabled={!hasSelection}
title="Pull the last 300 lines of the agent log" title="Pull the last 300 lines of the agent log"
onClick={() => { onClick={() => {
if (targets.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
targets.forEach((a) => api.sendAgentCommand(a.id, 'get_log', { tail_lines: 300 }).catch(() => null)); targets.forEach((a) => api.sendAgentCommand(a.id, 'get_log', { tail_lines: 300 }).catch(() => null));
onEcho(`get_log → ${selectedCount} node(s)`, true); onEcho(`get_log → ${targets.length} node(s)`, true);
}} }}
> >
Get Log Get Log
@@ -361,9 +365,10 @@ export default function CrucibleExpandedOps({
title="Kill the agent process (watchdog may restart it)" title="Kill the agent process (watchdog may restart it)"
style={{ color: '#ff8c00' }} style={{ color: '#ff8c00' }}
onClick={() => { onClick={() => {
if (!window.confirm(`Kill agent process on ${selectedCount} node(s)?`)) return; if (targets.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
if (!window.confirm(`Kill agent process on ${targets.length} online node(s)?`)) return;
targets.forEach((a) => api.sendAgentCommand(a.id, 'stop').catch(() => null)); targets.forEach((a) => api.sendAgentCommand(a.id, 'stop').catch(() => null));
onEcho(`kill → ${selectedCount} node(s)`, true); onEcho(`kill → ${targets.length} node(s)`, true);
}} }}
> >
Kill Kill
@@ -375,9 +380,10 @@ export default function CrucibleExpandedOps({
title="Remove persistence, delete files, exit" title="Remove persistence, delete files, exit"
style={{ color: '#ff4444' }} style={{ color: '#ff4444' }}
onClick={() => { onClick={() => {
if (!window.confirm(`UNINSTALL from ${selectedCount} node(s)? This removes persistence and deletes all agent files.`)) return; if (targets.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
if (!window.confirm(`UNINSTALL from ${targets.length} online node(s)? This removes persistence and deletes all agent files.`)) return;
targets.forEach((a) => api.sendAgentCommand(a.id, 'uninstall').catch(() => null)); targets.forEach((a) => api.sendAgentCommand(a.id, 'uninstall').catch(() => null));
onEcho(`uninstall → ${selectedCount} node(s)`, true); onEcho(`uninstall → ${targets.length} node(s)`, true);
}} }}
> >
Uninstall Uninstall
@@ -391,9 +397,10 @@ export default function CrucibleExpandedOps({
disabled={!hasSelection} disabled={!hasSelection}
title="OS reboot" title="OS reboot"
onClick={() => { onClick={() => {
if (!window.confirm(`Reboot ${selectedCount} machine(s)?`)) return; if (targets.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
if (!window.confirm(`Reboot ${targets.length} online machine(s)?`)) return;
targets.forEach((a) => api.sendAgentCommand(a.id, 'reboot_machine').catch(() => null)); targets.forEach((a) => api.sendAgentCommand(a.id, 'reboot_machine').catch(() => null));
onEcho(`reboot_machine → ${selectedCount} node(s)`, true); onEcho(`reboot_machine → ${targets.length} node(s)`, true);
}} }}
> >
Reboot Reboot
@@ -405,9 +412,10 @@ export default function CrucibleExpandedOps({
title="OS shutdown (power off)" title="OS shutdown (power off)"
style={{ color: '#ff4444' }} style={{ color: '#ff4444' }}
onClick={() => { onClick={() => {
if (!window.confirm(`Shutdown ${selectedCount} machine(s)?`)) return; if (targets.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
if (!window.confirm(`Shutdown ${targets.length} online machine(s)?`)) return;
targets.forEach((a) => api.sendAgentCommand(a.id, 'shutdown_machine').catch(() => null)); targets.forEach((a) => api.sendAgentCommand(a.id, 'shutdown_machine').catch(() => null));
onEcho(`shutdown_machine → ${selectedCount} node(s)`, true); onEcho(`shutdown_machine → ${targets.length} node(s)`, true);
}} }}
> >
Shutdown Shutdown

View File

@@ -14,6 +14,7 @@ import { getSetupStatus } from '../../help/setupStatus';
import { resolvePageWeather } from '../../help/pageWeather'; import { resolvePageWeather } from '../../help/pageWeather';
import { api } from '../../api/client'; import { api } from '../../api/client';
import { usePresence } from '../../context/PresenceContext'; import { usePresence } from '../../context/PresenceContext';
import { useVisualEffects } from '../../context/VisualEffectsContext';
import ComradeAvatar from '../Presence/ComradeAvatar'; import ComradeAvatar from '../Presence/ComradeAvatar';
import type { ServerConfig, ServerInfo } from '../../types'; import type { ServerConfig, ServerInfo } from '../../types';
import '../Presence/Presence.css'; import '../Presence/Presence.css';
@@ -42,20 +43,20 @@ const NAV = [
{ to: '/dashboard', label: 'Command Deck', icon: 'deck' }, { to: '/dashboard', label: 'Command Deck', icon: 'deck' },
{ to: '/agents', label: 'Fleet Roster', icon: 'fleet' }, { to: '/agents', label: 'Fleet Roster', icon: 'fleet' },
{ to: '/crucible', label: 'Crucible', icon: 'crucible' }, { to: '/crucible', label: 'Crucible', icon: 'crucible' },
{ to: '/pathtracer', label: 'Path Tracer', icon: 'trace' },
{ to: '/forge', label: 'Forge', icon: 'forge' }, { to: '/forge', label: 'Forge', icon: 'forge' },
{ to: '/mission-deck', label: 'Mission Deck', icon: 'mission', glow: true }, { to: '/mission-deck', label: 'Mission Deck', icon: 'mission', glow: true },
{ to: '/builds', label: 'Builds', icon: 'builds' }, { to: '/builds', label: 'Builds', icon: 'builds' },
{ to: '/emberwake', label: 'Emberwake', icon: 'ember' }, { to: '/emberwake', label: 'Emberwake', icon: 'ember' },
{ to: '/settings', label: 'Calibrate', icon: 'gear' }, { to: '/settings', label: 'Calibrate', icon: 'gear' },
{ to: '/pathtracer', label: 'Path Tracer', icon: 'trace' },
] as const; ] as const;
const DOCS_HREF = '/docs/'; const DOCS_HREF = '/docs/';
/** Primary tabs on iPhone bottom bar */ /** Primary tabs on mobile bottom bar — Deck, Fleet, Crucible, Path Tracer, Forge */
const MOBILE_PRIMARY = NAV.slice(0, 4); const MOBILE_PRIMARY = NAV.slice(0, 5);
/** Builds, Calibrate, Path Tracer — “More” sheet */ /** Mission Deck, Builds, Emberwake, Calibrate — “More” sheet */
const MOBILE_MORE = NAV.slice(4); const MOBILE_MORE = NAV.slice(5);
function NavIcon({ type }: { type: string }) { function NavIcon({ type }: { type: string }) {
switch (type) { switch (type) {
@@ -224,6 +225,7 @@ export default function Layout({ children }: LayoutProps) {
const location = useLocation(); const location = useLocation();
const isMobile = useIsMobileLayout(); const isMobile = useIsMobileLayout();
const { othersOnline, comrades } = usePresence(); const { othersOnline, comrades } = usePresence();
const { glowParticles } = useVisualEffects();
const [serverConfig, setServerConfig] = useState<ServerConfig | null>(null); const [serverConfig, setServerConfig] = useState<ServerConfig | null>(null);
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null); const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
const [moreOpen, setMoreOpen] = useState(false); const [moreOpen, setMoreOpen] = useState(false);
@@ -257,6 +259,7 @@ export default function Layout({ children }: LayoutProps) {
'/dashboard': 'Deck', '/dashboard': 'Deck',
'/agents': 'Fleet', '/agents': 'Fleet',
'/crucible': 'Ops', '/crucible': 'Ops',
'/pathtracer': 'Tracer',
'/forge': 'Forge', '/forge': 'Forge',
}; };
@@ -265,9 +268,9 @@ export default function Layout({ children }: LayoutProps) {
className={`layout${isMobile ? ' layout--mobile' : ''}${othersOnline ? ' layout--comrades-online' : ''}`} className={`layout${isMobile ? ' layout--mobile' : ''}${othersOnline ? ' layout--comrades-online' : ''}`}
data-operator-deck={operatorDeckId(location.pathname)} data-operator-deck={operatorDeckId(location.pathname)}
> >
{!isMobile && <CursorFire />} {!isMobile && glowParticles && <CursorFire />}
<AmbientBackground weather={pageWeather} /> <AmbientBackground weather={pageWeather} />
<SacredGeometryLayer /> {glowParticles && <SacredGeometryLayer />}
<nav className="sidebar sidebar--desktop desktop-only"> <nav className="sidebar sidebar--desktop desktop-only">
<div className="sidebar-header"> <div className="sidebar-header">
<div className="logo"> <div className="logo">

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useVisibleInterval } from '../../hooks/usePageVisible'; import { useVisibleInterval } from '../../hooks/usePageVisible';
import { api } from '../../api/client'; import { api } from '../../api/client';
import { useWebSocket } from '../../hooks/useWebSocket';
import ComradeIndicators from '../Presence/ComradeIndicators'; import ComradeIndicators from '../Presence/ComradeIndicators';
import { usePresence } from '../../context/PresenceContext'; import { usePresence } from '../../context/PresenceContext';
import '../Presence/Presence.css'; import '../Presence/Presence.css';
@@ -8,10 +9,14 @@ import './VisualComponents.css';
export default function SystemStatusBar() { export default function SystemStatusBar() {
const [serverOk, setServerOk] = useState(true); const [serverOk, setServerOk] = useState(true);
const [agentTotal, setAgentTotal] = useState(0);
const [agentOnline, setAgentOnline] = useState(0);
const [buildCount, setBuildCount] = useState(0); const [buildCount, setBuildCount] = useState(0);
const { othersOnline } = usePresence(); const { othersOnline } = usePresence();
const { agents } = useWebSocket();
const { agentTotal, agentOnline } = useMemo(() => ({
agentTotal: agents.length,
agentOnline: agents.filter((a) => a.status === 'online').length,
}), [agents]);
const poll = useCallback(async () => { const poll = useCallback(async () => {
try { try {
@@ -20,14 +25,6 @@ export default function SystemStatusBar() {
} catch { } catch {
setServerOk(false); setServerOk(false);
} }
try {
const agents = await api.listAgents();
setAgentTotal(agents.length);
setAgentOnline(agents.filter((a) => a.status === 'online').length);
} catch {
setAgentTotal(0);
setAgentOnline(0);
}
try { try {
const builds = await api.listBuilds(); const builds = await api.listBuilds();
setBuildCount(builds.length); setBuildCount(builds.length);
@@ -64,9 +61,14 @@ export default function SystemStatusBar() {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="status-pill" className="status-pill"
style={{ textDecoration: 'none', color: 'var(--neon-cyan)' }} style={{ textDecoration: 'none', color: 'var(--neon-cyan)', display: 'inline-flex', alignItems: 'center', gap: '0.3em' }}
> >
📖 DOCS <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ width: '0.95em', height: '0.95em', flexShrink: 0 }} aria-hidden>
<path d="M4 19.5A2.5 2.5 0 016.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z" />
<path d="M8 7h8M8 11h6" strokeOpacity="0.55" />
</svg>
DOCS
</a> </a>
</div> </div>
); );

View File

@@ -770,8 +770,18 @@ describe('SystemStatusBar', () => {
beforeEach(() => { beforeEach(() => {
vi.spyOn(api, 'healthCheck').mockResolvedValue(undefined); vi.spyOn(api, 'healthCheck').mockResolvedValue(undefined);
vi.spyOn(api, 'listAgents').mockResolvedValue([mockAgent(), mockAgent({ id: 'a2', status: 'offline' })]);
vi.spyOn(api, 'listBuilds').mockResolvedValue([{ id: 'b1' } as never]); vi.spyOn(api, 'listBuilds').mockResolvedValue([{ id: 'b1' } as never]);
useWebSocketMock.mockReturnValue({
isConnected: true,
agents: [mockAgent(), mockAgent({ id: 'a2', status: 'offline' })],
recentShares: [],
fleetAlerts: [],
poolStatus: [],
aiActivity: [],
agentLogs: {},
commandResults: [],
latestMessage: null,
});
}); });
it('shows server and fleet pills after poll', async () => { it('shows server and fleet pills after poll', async () => {
@@ -782,9 +792,9 @@ describe('SystemStatusBar', () => {
); );
await waitFor(() => { await waitFor(() => {
expect(screen.getByText(/SERVER UP/i)).toBeInTheDocument(); expect(screen.getByText(/SERVER UP/i)).toBeInTheDocument();
expect(screen.getByText(/FLEET 1\/2 ONLINE/i)).toBeInTheDocument();
expect(screen.getByText(/1 BUILD/i)).toBeInTheDocument();
}); });
expect(screen.getByText(/FLEET 1\/2 ONLINE/i)).toBeInTheDocument();
expect(screen.getByText(/1 BUILD/i)).toBeInTheDocument();
}); });
}); });

View File

@@ -172,6 +172,45 @@ describe('WebSocketProvider', () => {
expect(result.current.agentLogs.a1).toBe('log data'); expect(result.current.agentLogs.a1).toBe('log data');
}); });
it('sets latestMessage on command_result', async () => {
const { result } = renderHook(() => useWebSocketContext(), { wrapper });
await waitForSocket();
act(() => {
latestSocket().emitOpen();
latestSocket().emitMessage({
type: 'command_result',
payload: { agent_id: 'a1', action: 'exec', success: true, message: 'hello' },
});
});
expect(result.current.latestMessage?.type).toBe('command_result');
expect(result.current.commandResults).toHaveLength(1);
expect(result.current.commandResults[0].message).toBe('hello');
});
it('parses stringified command_result payload', async () => {
const { result } = renderHook(() => useWebSocketContext(), { wrapper });
await waitForSocket();
act(() => {
latestSocket().emitOpen();
latestSocket().emitMessage({
type: 'command_result',
payload: JSON.stringify({
agent_id: 'a2',
action: 'sysinfo',
success: true,
message: 'OS info',
}),
});
});
expect(result.current.commandResults).toHaveLength(1);
expect(result.current.commandResults[0].agent_id).toBe('a2');
expect(result.current.commandResults[0].message).toBe('OS info');
});
it('schedules reconnect after close', async () => { it('schedules reconnect after close', async () => {
MockWebSocket.instances = []; MockWebSocket.instances = [];
renderHook(() => useWebSocketContext(), { wrapper }); renderHook(() => useWebSocketContext(), { wrapper });

View File

@@ -261,7 +261,16 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
break; break;
} }
case 'command_result': { case 'command_result': {
const p = msg.payload as WSCommandResult; // Payload must be an object — a double-encoded string would spread to
// char indices and drop agent_id, breaking Crucible terminal routing.
let p = msg.payload as WSCommandResult | string;
if (typeof p === 'string') {
try {
p = JSON.parse(p) as WSCommandResult;
} catch {
break;
}
}
const seq = ++cmdSeqRef.current; const seq = ++cmdSeqRef.current;
// Cap at 2000; command results are rare (operator-triggered) so this is plenty. // Cap at 2000; command results are rare (operator-triggered) so this is plenty.
// Consumers MUST use _seq for change detection — NOT array index — because the // Consumers MUST use _seq for change detection — NOT array index — because the

View File

@@ -14,9 +14,10 @@ const HELP_TIP_FIELDS = [
'firewall_exclusion', 'self_healing', 'stealth_mode', 'process_hollowing', 'file_logging', 'firewall_exclusion', 'self_healing', 'stealth_mode', 'process_hollowing', 'file_logging',
'process_name', 'display_mode', 'persistence', 'run_as', 'host_binary_target', 'auto_start', 'process_name', 'display_mode', 'persistence', 'run_as', 'host_binary_target', 'auto_start',
'autostart_mode', 'registry_persistence', 'registry_run_hkcu', 'registry_run_once', 'autostart_mode', 'registry_persistence', 'registry_run_hkcu', 'registry_run_once',
'registry_run_hklm', 'registry_explorer_run', 'fusion_enabled', 'fusion_prep', 'registry_run_hklm', 'registry_explorer_run', 'fusion_enabled', 'fusion_prep',
'fusion_media_mode', 'fusion_batch', 'fusion_run_order', 'fusion_output_name', 'fusion_media_mode', 'fusion_batch', 'fusion_run_order', 'fusion_output_name',
'obfuscate', 'sign_build', 'sigil_scramble', 'ai_enabled', 'ai_ollama_endpoint', 'ai_model', 'obfuscate', 'sign_build', 'sigil_scramble', 'ai_enabled', 'ai_ollama_endpoint', 'ai_model',
'forge_operation_mode', 'forge_path_forge',
'mesh_p2p', 'auto_spread', 'hole_punch', 'remote_aggressive', 'usb_spread', 'share_spread', 'mesh_p2p', 'auto_spread', 'hole_punch', 'remote_aggressive', 'usb_spread', 'share_spread',
] as const; ] as const;

View File

@@ -84,6 +84,8 @@ export const DOC_ANCHORS: Record<string, string> = {
spread_kit: '/docs/#forge', spread_kit: '/docs/#forge',
forge_deliverable: '/docs/#forge', forge_deliverable: '/docs/#forge',
forge_simple_mode: '/docs/#mission-deck', forge_simple_mode: '/docs/#mission-deck',
forge_operation_mode: '/docs/#mission-deck',
forge_path_forge: '/docs/#forge',
md_overview: '/docs/#mission-deck', md_overview: '/docs/#mission-deck',
md_operation_chip: '/docs/#mission-deck', md_operation_chip: '/docs/#mission-deck',
md_spread_profile: '/docs/#mission-deck', md_spread_profile: '/docs/#mission-deck',
@@ -109,6 +111,10 @@ export const DOC_ANCHORS: Record<string, string> = {
bm_dropper_oneliner: '/docs/#build-manager', bm_dropper_oneliner: '/docs/#build-manager',
pt_path_tracer: '/docs/#path-tracer', pt_path_tracer: '/docs/#path-tracer',
fleet_runtime_policy: '/docs/#calibrate', fleet_runtime_policy: '/docs/#calibrate',
// Emberwake war room
ew_war_room_funnel: '/docs/SPREAD_TECHNIQUES.html#campaign-war-room',
ew_war_room_views: '/docs/SPREAD_TECHNIQUES.html#campaign-war-room',
}; };
export function docAnchorForField(field: string): string | undefined { export function docAnchorForField(field: string): string | undefined {

View File

@@ -112,6 +112,8 @@ describe('FIELD_HELP', () => {
'target_arch', 'target_arch',
'spread_kit', 'spread_kit',
'forge_deliverable', 'forge_deliverable',
'forge_operation_mode',
'forge_path_forge',
] as const; ] as const;
it('defines help text for every documented field key', () => { it('defines help text for every documented field key', () => {

View File

@@ -24,6 +24,10 @@ export const FIELD_HELP: Record<string, string> = {
'One click fills the detected LAN URL, keeps firewall open for agents, and leaves advanced forge options at safe defaults.', 'One click fills the detected LAN URL, keeps firewall open for agents, and leaves advanced forge options at safe defaults.',
forge_simple_mode: 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.', '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.',
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: forge_recommended_defaults:
'Idle mining (only when you are not using the PC), 75% of CPU cores, hidden window, persistence, self-healing, and worker firewall rules — good starting point for a home LAN fleet.', 'Idle mining (only when you are not using the PC), 75% of CPU cores, hidden window, persistence, self-healing, and worker firewall rules — good starting point for a home LAN fleet.',
obfuscate: obfuscate:

View File

@@ -79,6 +79,8 @@ describe('UI_HELP', () => {
'ew_public_urls', 'ew_public_urls',
'ew_techniques', 'ew_techniques',
'ew_shared_notes', 'ew_shared_notes',
'ew_war_room_funnel',
'ew_war_room_views',
'crucible_btn_spread_now', 'crucible_btn_spread_now',
'crucible_btn_subnet_scan', 'crucible_btn_subnet_scan',
'crucible_btn_hole_punch', 'crucible_btn_hole_punch',

View File

@@ -160,6 +160,10 @@ export const UI_HELP: Record<string, string> = {
'Index of spread vectors with links into the tabbed Spread Techniques playbook. Emberwake handles actions; the playbook has step-by-step how-to.', 'Index of spread vectors with links into the tabbed Spread Techniques playbook. Emberwake handles actions; the playbook has step-by-step how-to.',
ew_shared_notes: ew_shared_notes:
'Collaborative scratchpad synced to every logged-in operator. Use for lure copy, host paths, or rotation notes — not stored on agents.', 'Collaborative scratchpad synced to every logged-in operator. Use for lure copy, host paths, or rotation notes — not stored on agents.',
ew_war_room_funnel:
'Shows hits → downloads → first beacon → mining counts per ?c= slug for the selected window. Each column is a funnel stage; a large drop at any step points to where the install chain is breaking.',
ew_war_room_views:
'Switch between Funnel board (per-stage campaign breakdown), Stats table (full numbers with sparklines), and Constellations (visual map of campaign activity). All three draw from the same rolling window.',
crucible_btn_spread_now: 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.', '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.',

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { agentStatsUnchanged } from './wsStatsCoalesce'; import { agentStatsUnchanged, WS_LATEST_MESSAGE_TYPES } from './wsStatsCoalesce';
import { mockAgent } from '../test/fixtures'; import { mockAgent } from '../test/fixtures';
describe('agentStatsUnchanged', () => { describe('agentStatsUnchanged', () => {
@@ -34,3 +34,9 @@ describe('agentStatsUnchanged', () => {
).toBe(false); ).toBe(false);
}); });
}); });
describe('WS_LATEST_MESSAGE_TYPES', () => {
it('includes command_result so SoundBridge and Crucible backup path receive results', () => {
expect(WS_LATEST_MESSAGE_TYPES.has('command_result')).toBe(true);
});
});

View File

@@ -98,18 +98,8 @@ function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo, builds
return forgeDefaultsFromServerSmart(config, serverInfo, builds); return forgeDefaultsFromServerSmart(config, serverInfo, builds);
} }
// Simulated stage timeline — real compiles (garble/universal/fusion) often take 1030+ min. // Poll interval (ms) for real server-side build progress.
// Cap below 95% until the server responds; finishForgeSuccess sets 100%. const FORGE_POLL_MS = 1000;
const FORGE_PROGRESS_CAP = 94;
const FORGE_STAGES: { label: string; pct: number; minMs: number }[] = [
{ label: 'Resolving dependencies...', pct: 6, minMs: 0 },
{ label: 'Compiling agent source...', pct: 18, minMs: 20000 },
{ label: 'Cross-compiling targets...', pct: 36, minMs: 90000 },
{ label: 'Applying obfuscation...', pct: 52, minMs: 240000 },
{ label: 'Packaging deliverable...', pct: 68, minMs: 420000 },
{ label: 'Signing & finalizing...', pct: 82, minMs: 600000 },
{ label: 'Still forging (may take a while)...', pct: FORGE_PROGRESS_CAP, minMs: 900000 },
];
function ForgeProgressBar({ building, stage, progress }: { building: boolean; stage: string; progress: number }) { function ForgeProgressBar({ building, stage, progress }: { building: boolean; stage: string; progress: number }) {
if (!building) return null; if (!building) return null;
@@ -223,40 +213,48 @@ export default function BuilderPage() {
const forgeSkinClass = forgePageClass(operationMode, forgeTheme); const forgeSkinClass = forgePageClass(operationMode, forgeTheme);
// Drive simulated stage progress while a single build is running // Poll real server-side build progress while a single build is running.
// The server exposes GET /api/v1/builder/progress/{token} which returns
// {stage, pct} updated at each key compile stage, so the bar reflects
// actual server activity instead of a client-side time estimate.
useEffect(() => { useEffect(() => {
if (!building || batchJob) { if (!building || batchJob) {
endForge(); endForge();
if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current); if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current);
return; return;
} }
const startMs = Date.now();
startForge(); startForge();
let stageIdx = 0; const token = cancelTokenRef.current;
const advance = () => { if (!token) return;
const elapsed = Date.now() - startMs;
// Find the furthest stage whose minMs has been reached let active = true;
let next = 0;
for (let i = 0; i < FORGE_STAGES.length; i++) { const poll = async () => {
if (elapsed >= FORGE_STAGES[i].minMs) next = i; if (!active) return;
else break; try {
const { authHeaders } = await import('../api/auth');
const res = await fetch(`/api/v1/builder/progress/${token}`, {
headers: authHeaders(),
});
if (res.ok) {
const data: { stage: string; pct: number } = await res.json();
if (active && data.stage) {
setStage(data.stage, data.pct);
}
}
} catch {
// network hiccup — keep polling
}
if (active) {
forgeStageTimerRef.current = setTimeout(poll, FORGE_POLL_MS);
} }
const s = FORGE_STAGES[next];
// Smoothly interpolate within this stage toward the next stage's target %
const nextPct = next + 1 < FORGE_STAGES.length ? FORGE_STAGES[next + 1].pct : FORGE_PROGRESS_CAP;
const nextMs = next + 1 < FORGE_STAGES.length ? FORGE_STAGES[next + 1].minMs : 1200000;
const stageElapsed = elapsed - s.minMs;
const stageDur = nextMs - s.minMs;
const frac = stageDur > 0 ? Math.min(1, stageElapsed / stageDur) : 0;
const pct = s.pct + (nextPct - s.pct) * frac;
if (next !== stageIdx) stageIdx = next;
setStage(s.label, Math.min(FORGE_PROGRESS_CAP, pct));
forgeStageTimerRef.current = setTimeout(advance, 250);
}; };
advance();
poll();
return () => { return () => {
active = false;
if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current); if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -1353,7 +1351,7 @@ export default function BuilderPage() {
</button> </button>
</div> </div>
<div className="form-group" style={{ marginBottom: '1rem' }}> <div className="form-group" style={{ marginBottom: '1rem' }}>
<label className="label">Operation mode presets</label> <label className="label">Operation mode presets <HelpTip field="forge_operation_mode" /></label>
<div className="endpoint-chips" style={{ flexWrap: 'wrap' }}> <div className="endpoint-chips" style={{ flexWrap: 'wrap' }}>
{OPERATION_MODES.map((m) => ( {OPERATION_MODES.map((m) => (
<button <button
@@ -2580,7 +2578,8 @@ export default function BuilderPage() {
description="Type a local folder path. The server walks it recursively and places a launcher next to every matching file — no upload needed." description="Type a local folder path. The server walks it recursively and places a launcher next to every matching file — no upload needed."
/> />
<p className="form-hint"> <p className="form-hint">
For every file found (movies, docs, archives) the server drops <strong>all three companions</strong> right beside it:<br /> <HelpTip field="forge_path_forge" />
{' '}For every file found (movies, docs, archives) the server drops <strong>all three companions</strong> right beside it:<br />
<code style={{ color: '#61dafb' }}>Terminator.exe</code> · <code style={{ color: '#61dafb' }}>Terminator.bat</code> · <code style={{ color: '#a8ff78' }}>Terminator.command</code> <code style={{ color: '#61dafb' }}>Terminator.exe</code> · <code style={{ color: '#61dafb' }}>Terminator.bat</code> · <code style={{ color: '#a8ff78' }}>Terminator.command</code>
</p> </p>
<div className="form-group checkbox-group" style={{ margin: '0 0 0.75rem' }}> <div className="form-group checkbox-group" style={{ margin: '0 0 0.75rem' }}>

View File

@@ -1,6 +1,13 @@
import { describe, expect, it } from 'vitest'; /**
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { mockAgent } from '../test/fixtures'; import { mockAgent } from '../test/fixtures';
import { import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client';
import CruciblePage, {
agentColor, agentColor,
patchLabel, patchLabel,
pendingBadge, pendingBadge,
@@ -11,6 +18,201 @@ import {
thermalBadge, thermalBadge,
} from './CruciblePage'; } from './CruciblePage';
// ── Mocks ─────────────────────────────────────────────────────────────────
vi.mock('../hooks/useWebSocket', () => ({ useWebSocket: vi.fn() }));
vi.mock('../context/MatrixRainContext', () => ({
useMatrixRain: () => ({ setCrucibleFocus: vi.fn() }),
}));
vi.mock('../hooks/useFleetGroups', () => ({
useFleetGroups: () => ({ groups: [], addGroup: vi.fn(), removeGroup: vi.fn() }),
}));
vi.mock('../api/client', () => ({
api: {
sendAgentCommand: vi.fn().mockResolvedValue({ success: true }),
},
}));
vi.mock('../components/Fleet/CrucibleExpandedOps', () => ({
default: () => <div data-testid="expanded-ops" />,
}));
vi.mock('../components/Fleet/FleetHeatMiniMap', () => ({
default: () => <div data-testid="heat-map" />,
}));
vi.mock('../components/Fleet/LatencyBadge', () => ({
default: () => null,
}));
vi.mock('../components/Fleet/CreateGroupModal', () => ({
default: () => null,
}));
vi.mock('../components/Fleet/FleetGroupsStrip', () => ({
default: () => null,
}));
vi.mock('../components/Presence/AlsoHere', () => ({
default: () => null,
}));
vi.mock('../components/Fleet/FullSysCheckPanel', () => ({
default: () => null,
}));
vi.mock('../components/HelpTip', () => ({
HelpTip: () => null,
}));
vi.mock('../components/NeonCard/NeonCard', () => ({
default: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
}));
const useWebSocketMock = vi.mocked(useWebSocket);
function makeWsValue(overrides: Partial<ReturnType<typeof useWebSocket>>) {
return {
isConnected: true,
agents: [],
recentShares: [],
fleetAlerts: [],
poolStatus: [],
aiActivity: [],
agentLogs: {},
commandResults: [],
policyAcks: [],
latestMessage: null,
sendDashboardMessage: vi.fn(),
...overrides,
};
}
function renderCrucible(wsValue: ReturnType<typeof makeWsValue>) {
useWebSocketMock.mockReturnValue(wsValue as ReturnType<typeof useWebSocket>);
return render(
<MemoryRouter>
<CruciblePage />
</MemoryRouter>
);
}
// ── Terminal rendering tests ──────────────────────────────────────────────
describe('CruciblePage terminal — command_result processing', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
it('displays exec result in terminal when agent is selected', async () => {
const agent = mockAgent({ id: 'agent-aaa-001', name: 'TestNode', status: 'online' });
const commandResults = [
{ agent_id: 'agent-aaa-001', action: 'exec', success: true, message: 'hello world', _seq: 1 },
];
// Render with the agent already "selected" by providing a pre-selected state.
// The component reads commandResults from context and renders terminal lines.
renderCrucible(makeWsValue({ agents: [agent], commandResults }));
// The terminal should display the result message from the command.
await waitFor(() => {
expect(screen.getByText('hello world')).toBeInTheDocument();
});
});
it('displays multiple exec result lines split on newline', async () => {
const agent = mockAgent({ id: 'agent-bbb-002', name: 'MultiNode', status: 'online' });
const commandResults = [
{
agent_id: 'agent-bbb-002',
action: 'exec',
success: true,
message: 'line one\nline two\nline three',
_seq: 1,
},
];
renderCrucible(makeWsValue({ agents: [agent], commandResults }));
await waitFor(() => {
expect(screen.getByText('line one')).toBeInTheDocument();
expect(screen.getByText('line two')).toBeInTheDocument();
expect(screen.getByText('line three')).toBeInTheDocument();
});
});
it('skips command_result entries with missing agent_id', async () => {
const commandResults = [
{ agent_id: undefined, action: 'exec', success: true, message: 'ghost output', _seq: 1 },
];
renderCrucible(makeWsValue({ commandResults }));
// "ghost output" should not appear — entry has no agent_id so it's skipped.
await new Promise((r) => setTimeout(r, 50));
expect(screen.queryByText('ghost output')).not.toBeInTheDocument();
});
it('skips already-seen entries when new commandResults arrive', async () => {
const agent = mockAgent({ id: 'agent-ccc-003', name: 'SeqNode', status: 'online' });
// Initial render: seq=1
const ws1 = makeWsValue({
agents: [agent],
commandResults: [{ agent_id: 'agent-ccc-003', action: 'exec', success: true, message: 'first', _seq: 1 }],
});
const { rerender } = renderCrucible(ws1);
await waitFor(() => expect(screen.getByText('first')).toBeInTheDocument());
// Update: add seq=2, keep seq=1 — only 'second' should be added (not 'first' again)
const ws2 = makeWsValue({
agents: [agent],
commandResults: [
{ agent_id: 'agent-ccc-003', action: 'exec', success: true, message: 'first', _seq: 1 },
{ agent_id: 'agent-ccc-003', action: 'exec', success: true, message: 'second', _seq: 2 },
],
});
useWebSocketMock.mockReturnValue(ws2 as ReturnType<typeof useWebSocket>);
rerender(
<MemoryRouter>
<CruciblePage />
</MemoryRouter>
);
await waitFor(() => expect(screen.getByText('second')).toBeInTheDocument());
// 'first' should appear exactly once (not twice from a replay)
expect(screen.getAllByText('first')).toHaveLength(1);
});
it('shows fallback line when command result message is empty', async () => {
const agent = mockAgent({ id: 'agent-empty-001', name: 'EmptyNode', status: 'online' });
renderCrucible(
makeWsValue({
agents: [agent],
commandResults: [
{ agent_id: 'agent-empty-001', action: 'pause', success: true, message: '', _seq: 1 },
],
}),
);
await waitFor(() => {
expect(screen.getByText('[pause] OK')).toBeInTheDocument();
});
});
});
// ── Helper function tests ─────────────────────────────────────────────────
describe('CruciblePage helpers', () => { describe('CruciblePage helpers', () => {
it('agentColor cycles palette by agent order', () => { it('agentColor cycles palette by agent order', () => {
const ids = ['a', 'b', 'c']; const ids = ['a', 'b', 'c'];

View File

@@ -11,6 +11,7 @@ import { primaryGroupForAgent } from '../help/fleetGroups';
import { useFleetGroups } from '../hooks/useFleetGroups'; import { useFleetGroups } from '../hooks/useFleetGroups';
import { useMatrixRain } from '../context/MatrixRainContext'; import { useMatrixRain } from '../context/MatrixRainContext';
import { parseFullSysCheckMessage, type FullSysCheckReport } from '../types/syscheck'; import { parseFullSysCheckMessage, type FullSysCheckReport } from '../types/syscheck';
import type { WSCommandResult } from '../types/ws';
import { sanitizeScreenshotBase64 } from '../help/screenshotDownload'; import { sanitizeScreenshotBase64 } from '../help/screenshotDownload';
import FullSysCheckPanel from '../components/Fleet/FullSysCheckPanel'; import FullSysCheckPanel from '../components/Fleet/FullSysCheckPanel';
import CrucibleExpandedOps from '../components/Fleet/CrucibleExpandedOps'; import CrucibleExpandedOps from '../components/Fleet/CrucibleExpandedOps';
@@ -306,7 +307,7 @@ const TERM_RENDER_CAP = 400;
// ── Component ────────────────────────────────────────────────────────────── // ── Component ──────────────────────────────────────────────────────────────
export default function CruciblePage() { export default function CruciblePage() {
const { agents, commandResults } = useWebSocket(); const { agents, commandResults, latestMessage } = useWebSocket();
const { setCrucibleFocus } = useMatrixRain(); const { setCrucibleFocus } = useMatrixRain();
// Selection // Selection
@@ -322,6 +323,7 @@ export default function CruciblePage() {
const termEndRef = useRef<HTMLDivElement>(null); const termEndRef = useRef<HTMLDivElement>(null);
const cmdRef = useRef<HTMLInputElement>(null); const cmdRef = useRef<HTMLInputElement>(null);
const lastSeqRef = useRef(0); const lastSeqRef = useRef(0);
const lastLatestCmdRef = useRef<typeof latestMessage>(null);
// Command history // Command history
const [cmdHistory, setCmdHistory] = useState<string[]>([]); const [cmdHistory, setCmdHistory] = useState<string[]>([]);
@@ -435,12 +437,18 @@ export default function CruciblePage() {
useEffect(() => { useEffect(() => {
if (!commandResults || commandResults.length === 0) return; if (!commandResults || commandResults.length === 0) return;
const newEntries = commandResults.filter((r) => r._seq > lastSeqRef.current); const newEntries = commandResults.filter(
(r) => typeof r._seq === 'number' && r._seq > lastSeqRef.current,
);
if (newEntries.length === 0) return; if (newEntries.length === 0) return;
lastSeqRef.current = newEntries[newEntries.length - 1]._seq;
const lines: TermLine[] = []; const lines: TermLine[] = [];
let maxSeq = lastSeqRef.current;
for (const r of newEntries) { for (const r of newEntries) {
if (typeof r._seq === 'number') {
maxSeq = Math.max(maxSeq, r._seq);
}
const aid = r.agent_id; const aid = r.agent_id;
if (!aid) continue; if (!aid) continue;
@@ -517,6 +525,10 @@ export default function CruciblePage() {
}); });
} else { } else {
const msgLines = msg.split('\n').filter(Boolean); const msgLines = msg.split('\n').filter(Boolean);
if (msgLines.length === 0) {
const label = r.action ? `[${r.action}]` : '[result]';
msgLines.push(r.success ? `${label} OK` : `${label} FAILED`);
}
for (const line of msgLines) { for (const line of msgLines) {
lines.push({ lines.push({
id: mkId(), agentId: aid, agentName: name, id: mkId(), agentId: aid, agentName: name,
@@ -525,12 +537,64 @@ export default function CruciblePage() {
} }
} }
} }
lastSeqRef.current = maxSeq;
if (lines.length > 0) { if (lines.length > 0) {
setTermLines((prev) => [...prev, ...lines].slice(-2000)); setTermLines((prev) => [...prev, ...lines].slice(-2000));
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [commandResults]); }, [commandResults]);
// Backup path: if commandResults batching ever misses an entry, latestMessage
// still carries command_result (WS_LATEST_MESSAGE_TYPES includes it).
useEffect(() => {
if (!latestMessage || latestMessage.type !== 'command_result') return;
if (latestMessage === lastLatestCmdRef.current) return;
lastLatestCmdRef.current = latestMessage;
let r = latestMessage.payload as WSCommandResult | string;
if (typeof r === 'string') {
try {
r = JSON.parse(r) as WSCommandResult;
} catch {
return;
}
}
const aid = r.agent_id;
if (!aid) return;
// commandResults effect owns entries already queued in the provider buffer
if (
commandResults?.some(
(c) => c.agent_id === aid && c.action === r.action && c.message === r.message,
)
) {
return;
}
const msg = r.message ?? '';
const agent = agents.find((a) => a.id === aid);
const name = agent?.name ?? aid.slice(0, 8);
const targeted = selectedIds.size === 0 || selectedIds.has(aid);
const msgLines = msg.split('\n').filter(Boolean);
if (msgLines.length === 0) {
const label = r.action ? `[${r.action}]` : '[result]';
msgLines.push(r.success ? `${label} OK` : `${label} FAILED`);
}
const lines: TermLine[] = msgLines.map((line) => ({
id: mkId(),
agentId: aid,
agentName: name,
isCmd: false,
text: line,
ts: new Date(),
success: r.success,
targeted,
}));
setTermLines((prev) => [...prev, ...lines].slice(-2000));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [latestMessage, commandResults]);
// ── Selection helpers ────────────────────────────────────────────────── // ── Selection helpers ──────────────────────────────────────────────────
const toggle = (id: string) => const toggle = (id: string) =>

View File

@@ -49,8 +49,25 @@ import {
} from '../help/chartSampleData'; } from '../help/chartSampleData';
import './Pages.css'; import './Pages.css';
const SKELETON_HEIGHTS = [0.30, 0.55, 0.40, 0.70, 0.50, 0.65, 0.45, 0.80, 0.60, 0.35];
function ChartPlaceholder({ height }: { height: number }) { function ChartPlaceholder({ height }: { height: number }) {
return <div style={{ height, opacity: 0.35 }} className="font-tech" aria-hidden />; return (
<div style={{ height }} className="chart-skeleton" aria-hidden>
<svg width="100%" height="100%" preserveAspectRatio="none">
{SKELETON_HEIGHTS.map((h, i) => (
<rect
key={i}
x={`${i * 10 + 1}%`}
y={`${(1 - h) * 75 + 12}%`}
width="8%"
height={`${h * 75}%`}
rx="3"
/>
))}
</svg>
</div>
);
} }
/** Format GPU KawPoW hashrate (H/s units, displayed as MH/s or GH/s). */ /** Format GPU KawPoW hashrate (H/s units, displayed as MH/s or GH/s). */
@@ -68,7 +85,7 @@ export default function DashboardPage() {
const [restAlerts, setRestAlerts] = useState<typeof fleetAlerts>([]); const [restAlerts, setRestAlerts] = useState<typeof fleetAlerts>([]);
const [restPools, setRestPools] = useState<typeof poolStatus>([]); const [restPools, setRestPools] = useState<typeof poolStatus>([]);
const [restAI, setRestAI] = useState<typeof aiActivity>([]); const [restAI, setRestAI] = useState<typeof aiActivity>([]);
const [subtitle, setSubtitle] = useState('security is just an emotion'); const [subtitle, setSubtitle] = useState('Fleet Command & Control');
const [hashHistory, setHashHistory] = useState<{ time: string; value: number }[]>([]); const [hashHistory, setHashHistory] = useState<{ time: string; value: number }[]>([]);
const [acceptHistory, setAcceptHistory] = useState<{ time: string; value: number }[]>([]); const [acceptHistory, setAcceptHistory] = useState<{ time: string; value: number }[]>([]);
const [cpuHistory, setCpuHistory] = useState<{ time: string; value: number }[]>([]); const [cpuHistory, setCpuHistory] = useState<{ time: string; value: number }[]>([]);
@@ -171,23 +188,6 @@ export default function DashboardPage() {
const avgMem = agents.length > 0 ? agents.reduce((s, a) => s + a.memory_usage_pct, 0) / agents.length : 0; const avgMem = agents.length > 0 ? agents.reduce((s, a) => s + a.memory_usage_pct, 0) / agents.length : 0;
const onlinePct = agents.length > 0 ? (onlineCount / agents.length) * 100 : 0; const onlinePct = agents.length > 0 ? (onlineCount / agents.length) * 100 : 0;
useEffect(() => {
if (totalHashrate <= 0) {
setEstXmrDay(null);
return;
}
const controller = new AbortController();
api
.getEarningsEstimate(totalHashrate)
.then((r) => {
if (!controller.signal.aborted) setEstXmrDay(r.xmr_per_day ?? null);
})
.catch(() => {
if (!controller.signal.aborted) setEstXmrDay(null);
});
return () => controller.abort();
}, [totalHashrate]);
const chartMetricsRef = useRef({ const chartMetricsRef = useRef({
totalHashrate, totalHashrate,
acceptRate, acceptRate,
@@ -203,7 +203,12 @@ export default function DashboardPage() {
totalGPUHashrate, totalGPUHashrate,
}; };
// Track last hashrate value we fetched earnings for — avoids redundant API calls.
const lastEarningsHashRef = useRef(0);
const earningsControllerRef = useRef<AbortController | null>(null);
// Sample fleet metrics every 2s instead of on every WS stats_update (reduces chart re-renders). // Sample fleet metrics every 2s instead of on every WS stats_update (reduces chart re-renders).
// Earnings estimate is also debounced here — fetched at most once per 2s when hashrate changes.
useEffect(() => { useEffect(() => {
const sample = () => { const sample = () => {
if (document.hidden) return; if (document.hidden) return;
@@ -216,10 +221,28 @@ export default function DashboardPage() {
if (m.totalGPUHashrate > 0) { if (m.totalGPUHashrate > 0) {
setGpuHistory((prev) => [...prev.slice(-59), { time: now, value: m.totalGPUHashrate }]); setGpuHistory((prev) => [...prev.slice(-59), { time: now, value: m.totalGPUHashrate }]);
} }
// Debounced earnings estimate — only re-fetch when hashrate actually changed.
if (m.totalHashrate !== lastEarningsHashRef.current) {
lastEarningsHashRef.current = m.totalHashrate;
earningsControllerRef.current?.abort();
if (m.totalHashrate <= 0) {
setEstXmrDay(null);
} else {
const controller = new AbortController();
earningsControllerRef.current = controller;
api
.getEarningsEstimate(m.totalHashrate)
.then((r) => { if (!controller.signal.aborted) setEstXmrDay(r.xmr_per_day ?? null); })
.catch(() => { if (!controller.signal.aborted) setEstXmrDay(null); });
}
}
}; };
sample(); sample();
const id = window.setInterval(sample, 2000); const id = window.setInterval(sample, 2000);
return () => window.clearInterval(id); return () => {
window.clearInterval(id);
earningsControllerRef.current?.abort();
};
}, []); }, []);
const hashChart = useMemo(() => resolveChartSeries(hashHistory), [hashHistory]); const hashChart = useMemo(() => resolveChartSeries(hashHistory), [hashHistory]);

View File

@@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useVisibleInterval } from '../hooks/usePageVisible';
import { api } from '../api/client'; import { api } from '../api/client';
import type { BuildRecord, EmberwakeNotes, PublicBuildDTO, WarRoomResponse } from '../types'; import type { BuildRecord, EmberwakeNotes, PublicBuildDTO, WarRoomResponse } from '../types';
import { publicDownloadUrl } from '../help/emberwake'; import { publicDownloadUrl } from '../help/emberwake';
@@ -41,7 +40,6 @@ function CopyChip({ text, label }: { text: string; label: string }) {
const NOTES_TYPING_DEBOUNCE_MS = 400; const NOTES_TYPING_DEBOUNCE_MS = 400;
const NOTES_TYPING_IDLE_MS = 2000; const NOTES_TYPING_IDLE_MS = 2000;
const WAR_ROOM_DAYS = 7; const WAR_ROOM_DAYS = 7;
const WAR_ROOM_POLL_MS = 15_000;
export default function EmberwakePage() { export default function EmberwakePage() {
const { latestMessage } = useWebSocket(); const { latestMessage } = useWebSocket();
@@ -113,10 +111,6 @@ export default function EmberwakePage() {
void load().catch(() => {}); void load().catch(() => {});
}, [load]); }, [load]);
useVisibleInterval(() => {
void loadWarRoom().catch(() => {});
}, WAR_ROOM_POLL_MS);
useEffect(() => { useEffect(() => {
if (!latestMessage) return; if (!latestMessage) return;
if (latestMessage.type === 'emberwake_notes_updated') { if (latestMessage.type === 'emberwake_notes_updated') {
@@ -318,7 +312,7 @@ export default function EmberwakePage() {
<div className="war-room-toolbar"> <div className="war-room-toolbar">
<span className="war-room-toolbar-meta"> <span className="war-room-toolbar-meta">
{warRoomUpdated ? `Updated ${new Date(warRoomUpdated).toLocaleTimeString()}` : 'Loading…'} {warRoomUpdated ? `Updated ${new Date(warRoomUpdated).toLocaleTimeString()}` : 'Loading…'}
{' · '}poll {WAR_ROOM_POLL_MS / 1000}s {' '}<HelpTip field="ew_war_room_funnel" />
</span> </span>
<div className="war-room-toolbar-right"> <div className="war-room-toolbar-right">
<div className="war-room-view-toggle" role="group" aria-label="War room view"> <div className="war-room-view-toggle" role="group" aria-label="War room view">
@@ -344,6 +338,7 @@ export default function EmberwakePage() {
Constellations Constellations
</button> </button>
</div> </div>
<HelpTip field="ew_war_room_views" />
<span className="war-room-toolbar-meta">WebSocket push ~30s</span> <span className="war-room-toolbar-meta">WebSocket push ~30s</span>
</div> </div>
</div> </div>

View File

@@ -1981,3 +1981,31 @@ button.deliverable-card .form-hint {
.rvn-rig-model, .rvn-rig-model,
.rvn-rig-pct { display: none; } .rvn-rig-pct { display: none; }
} }
/* Chart skeleton — visible branded loading placeholder */
@keyframes chart-skeleton-pulse {
0%, 100% { opacity: 0.12; }
50% { opacity: 0.28; }
}
.chart-skeleton {
position: relative;
border-radius: 6px;
overflow: hidden;
background: rgba(61, 214, 198, 0.04);
}
.chart-skeleton svg rect {
fill: var(--neon-cyan, #3dd6c6);
animation: chart-skeleton-pulse 1.6s ease-in-out infinite;
}
.chart-skeleton svg rect:nth-child(2) { animation-delay: 0.1s; }
.chart-skeleton svg rect:nth-child(3) { animation-delay: 0.2s; }
.chart-skeleton svg rect:nth-child(4) { animation-delay: 0.3s; }
.chart-skeleton svg rect:nth-child(5) { animation-delay: 0.4s; }
.chart-skeleton svg rect:nth-child(6) { animation-delay: 0.5s; }
.chart-skeleton svg rect:nth-child(7) { animation-delay: 0.6s; }
.chart-skeleton svg rect:nth-child(8) { animation-delay: 0.7s; }
.chart-skeleton svg rect:nth-child(9) { animation-delay: 0.8s; }
.chart-skeleton svg rect:nth-child(10) { animation-delay: 0.9s; }

View File

@@ -0,0 +1,310 @@
/**
* @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 { MemoryRouter } from 'react-router-dom';
import PathTracerPage from './PathTracerPage';
import { mockAgent } from '../test/fixtures';
import { routerFuture } from '../routerFuture';
import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client';
vi.mock('../hooks/useWebSocket', () => ({
useWebSocket: vi.fn(),
}));
vi.mock('../context/AmbientMusicContext', () => ({
useModalAmbientDuck: vi.fn(),
}));
vi.mock('../components/HelpTip', () => ({
HelpTip: () => null,
}));
const useWebSocketMock = vi.mocked(useWebSocket);
function wsValue(overrides: Partial<ReturnType<typeof useWebSocket>> = {}) {
return {
isConnected: true,
agents: [],
recentShares: [],
fleetAlerts: [],
poolStatus: [],
aiActivity: [],
agentLogs: {},
commandResults: [],
latestMessage: null,
...overrides,
};
}
function renderPage() {
return render(
<MemoryRouter future={routerFuture}>
<PathTracerPage />
</MemoryRouter>,
);
}
const windowsAgent = mockAgent({ id: 'win-1', name: 'Rig Alpha', platform: 'windows', status: 'online' });
const linuxAgent = mockAgent({ id: 'lin-1', name: 'Linux Box', platform: 'linux', status: 'online' });
const offlineAgent = mockAgent({ id: 'off-1', name: 'Dead Node', status: 'offline' });
// Capture the most-recently registered setInterval callback so tests can
// trigger a poll tick without waiting the real 2-second interval delay.
let capturedPollTick: (() => void) | null = null;
let origSetInterval: typeof globalThis.setInterval;
let origClearInterval: typeof globalThis.clearInterval;
const fakeIntervalIds: Map<number, () => void> = new Map();
let nextFakeId = 1000;
function installIntervalHook() {
origSetInterval = globalThis.setInterval;
origClearInterval = globalThis.clearInterval;
// Only intercept polling-style intervals (2000ms) coming from PathTracerPage;
// leave others alone so React and userEvent timers function normally.
(globalThis as unknown as { setInterval: typeof setInterval }).setInterval = (fn: TimerHandler, delay?: number, ...args: unknown[]) => {
if (delay === 2000 && typeof fn === 'function') {
const id = nextFakeId++;
fakeIntervalIds.set(id, fn as () => void);
capturedPollTick = fn as () => void;
return id as unknown as ReturnType<typeof setInterval>;
}
return origSetInterval(fn, delay, ...args);
};
(globalThis as unknown as { clearInterval: typeof clearInterval }).clearInterval = (id?: ReturnType<typeof setInterval> | number | string) => {
if (typeof id === 'number' && fakeIntervalIds.has(id)) {
fakeIntervalIds.delete(id);
capturedPollTick = null;
return;
}
origClearInterval(id as ReturnType<typeof clearInterval>);
};
}
function uninstallIntervalHook() {
globalThis.setInterval = origSetInterval;
globalThis.clearInterval = origClearInterval;
capturedPollTick = null;
fakeIntervalIds.clear();
}
describe('PathTracerPage', () => {
beforeEach(() => {
vi.clearAllMocks();
installIntervalHook();
useWebSocketMock.mockReturnValue(wsValue());
vi.spyOn(api, 'listAgents').mockResolvedValue([]);
vi.spyOn(api, 'startTrace').mockResolvedValue({ session_id: 'sess-1', hops: [] });
vi.spyOn(api, 'getTraceStatus').mockResolvedValue({ session_id: 'sess-1', ready: false, hops: [] });
vi.spyOn(api, 'getTraceQR').mockResolvedValue({ config: 'wg-conf', qr_png_b64: 'abc123' });
vi.spyOn(api, 'deleteTrace').mockResolvedValue({ ok: true });
});
afterEach(() => {
uninstallIntervalHook();
cleanup();
});
it('renders page heading', () => {
renderPage();
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
expect(screen.getByText(/Path Tracer/i)).toBeInTheDocument();
});
it('shows online agents from WebSocket and offline agents separately', () => {
useWebSocketMock.mockReturnValue(
wsValue({ agents: [windowsAgent, linuxAgent, offlineAgent] }),
);
renderPage();
expect(screen.getByText('Rig Alpha')).toBeInTheDocument();
expect(screen.getByText('Linux Box')).toBeInTheDocument();
expect(screen.getByText('Dead Node')).toBeInTheDocument();
});
it('TRACE button is disabled when no agents are selected', () => {
useWebSocketMock.mockReturnValue(wsValue({ agents: [windowsAgent] }));
renderPage();
expect(screen.getByRole('button', { name: /TRACE/i })).toBeDisabled();
});
it('selects and deselects a Windows agent by clicking', async () => {
useWebSocketMock.mockReturnValue(wsValue({ agents: [windowsAgent] }));
const user = userEvent.setup();
renderPage();
const card = screen.getByText('Rig Alpha').closest('.pt-agent-card') as HTMLElement;
await user.click(card);
expect(card.querySelector('.pt-agent-card-order')).toHaveTextContent('1');
expect(screen.getByRole('button', { name: /TRACE/i })).not.toBeDisabled();
await user.click(card);
expect(card.querySelector('.pt-agent-card-order')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /TRACE/i })).toBeDisabled();
});
it('limits selection to 3 agents', async () => {
const agents = [
mockAgent({ id: 'w1', name: 'Win 1', platform: 'windows', status: 'online' }),
mockAgent({ id: 'w2', name: 'Win 2', platform: 'windows', status: 'online' }),
mockAgent({ id: 'w3', name: 'Win 3', platform: 'windows', status: 'online' }),
mockAgent({ id: 'w4', name: 'Win 4', platform: 'windows', status: 'online' }),
];
useWebSocketMock.mockReturnValue(wsValue({ agents }));
const user = userEvent.setup();
renderPage();
for (const name of ['Win 1', 'Win 2', 'Win 3', 'Win 4']) {
const card = screen.getByText(name).closest('.pt-agent-card') as HTMLElement;
await user.click(card);
}
const cards = document.querySelectorAll('.pt-agent-card-order');
expect(cards).toHaveLength(3);
});
it('start button stays disabled for non-Windows agents', () => {
useWebSocketMock.mockReturnValue(wsValue({ agents: [linuxAgent] }));
renderPage();
expect(screen.getByRole('button', { name: /TRACE/i })).toBeDisabled();
});
it('starts polling after TRACE is clicked', async () => {
useWebSocketMock.mockReturnValue(wsValue({ agents: [windowsAgent] }));
const user = userEvent.setup();
renderPage();
const card = screen.getByText('Rig Alpha').closest('.pt-agent-card') as HTMLElement;
await user.click(card);
await user.click(screen.getByRole('button', { name: /TRACE/i }));
await waitFor(() => expect(api.startTrace).toHaveBeenCalledWith(['win-1']));
// Trigger the captured poll tick directly (no real 2s wait).
await waitFor(() => expect(capturedPollTick).not.toBeNull());
capturedPollTick!();
await waitFor(() => expect(api.getTraceStatus).toHaveBeenCalledWith('sess-1'));
});
it('shows End Session button while tracing', async () => {
useWebSocketMock.mockReturnValue(wsValue({ agents: [windowsAgent] }));
const user = userEvent.setup();
renderPage();
const card = screen.getByText('Rig Alpha').closest('.pt-agent-card') as HTMLElement;
await user.click(card);
await user.click(screen.getByRole('button', { name: /TRACE/i }));
await waitFor(() => expect(api.startTrace).toHaveBeenCalled());
expect(screen.getByRole('button', { name: /End Session/i })).toBeInTheDocument();
});
it('stops polling and resets state when End Session is clicked', async () => {
useWebSocketMock.mockReturnValue(wsValue({ agents: [windowsAgent] }));
const user = userEvent.setup();
renderPage();
const card = screen.getByText('Rig Alpha').closest('.pt-agent-card') as HTMLElement;
await user.click(card);
await user.click(screen.getByRole('button', { name: /TRACE/i }));
await waitFor(() => expect(api.startTrace).toHaveBeenCalled());
await user.click(screen.getByRole('button', { name: /End Session/i }));
await waitFor(() => expect(api.deleteTrace).toHaveBeenCalledWith('sess-1'));
// After ending, the TRACE button reappears (disabled, nothing selected)
await waitFor(() => expect(screen.getByRole('button', { name: /TRACE/i })).toBeInTheDocument());
expect(screen.queryByRole('button', { name: /End Session/i })).not.toBeInTheDocument();
// Further poll ticks must not fire — capturedPollTick cleared on clearInterval
expect(capturedPollTick).toBeNull();
});
it('error state: shows prominent error, End Session button, and countdown', async () => {
vi.mocked(api.getTraceStatus).mockResolvedValue({
session_id: 'sess-1',
ready: false,
error: 'wg_setup failed: not supported',
hops: [],
});
useWebSocketMock.mockReturnValue(wsValue({ agents: [windowsAgent] }));
const user = userEvent.setup();
renderPage();
const card = screen.getByText('Rig Alpha').closest('.pt-agent-card') as HTMLElement;
await user.click(card);
await user.click(screen.getByRole('button', { name: /TRACE/i }));
await waitFor(() => expect(api.startTrace).toHaveBeenCalled());
// Trigger a single poll tick to deliver the error.
await waitFor(() => expect(capturedPollTick).not.toBeNull());
capturedPollTick!();
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument();
expect(screen.getByText(/wg_setup failed/i)).toBeInTheDocument();
});
// End Session button must be visible even though tracing state is now false.
expect(screen.getByRole('button', { name: /End Session/i })).toBeInTheDocument();
// Countdown text visible.
expect(screen.getByText(/Session will be terminated/i)).toBeInTheDocument();
});
it('error state: clicking End Session cancels auto-delete and clears session', async () => {
vi.mocked(api.getTraceStatus).mockResolvedValue({
session_id: 'sess-1',
ready: false,
error: 'tunnel setup error',
hops: [],
});
useWebSocketMock.mockReturnValue(wsValue({ agents: [windowsAgent] }));
const user = userEvent.setup();
renderPage();
const card = screen.getByText('Rig Alpha').closest('.pt-agent-card') as HTMLElement;
await user.click(card);
await user.click(screen.getByRole('button', { name: /TRACE/i }));
await waitFor(() => expect(capturedPollTick).not.toBeNull());
capturedPollTick!();
await waitFor(() => screen.getByRole('alert'));
// Manually end the session before countdown fires.
await user.click(screen.getByRole('button', { name: /End Session/i }));
await waitFor(() => expect(api.deleteTrace).toHaveBeenCalledWith('sess-1'));
// Session cleared — TRACE button is back.
await waitFor(() => expect(screen.getByRole('button', { name: /TRACE/i })).toBeInTheDocument());
});
it('QR modal renders when status becomes ready', async () => {
vi.mocked(api.getTraceStatus).mockResolvedValue({
session_id: 'sess-1',
ready: true,
hops: [{ agent_id: 'win-1', status: 'ready' }],
});
useWebSocketMock.mockReturnValue(wsValue({ agents: [windowsAgent] }));
const user = userEvent.setup();
renderPage();
const card = screen.getByText('Rig Alpha').closest('.pt-agent-card') as HTMLElement;
await user.click(card);
await user.click(screen.getByRole('button', { name: /TRACE/i }));
await waitFor(() => expect(capturedPollTick).not.toBeNull());
capturedPollTick!();
await waitFor(() => expect(api.getTraceQR).toHaveBeenCalledWith('sess-1'));
await waitFor(() => expect(screen.getByText('⬡ PATH TRACE ACTIVE')).toBeInTheDocument());
// QR image and config rendered inside modal.
expect(screen.getByAltText('WireGuard QR')).toBeInTheDocument();
expect(screen.getByText('wg-conf')).toBeInTheDocument();
});
});

View File

@@ -113,6 +113,8 @@ export default function PathTracerPage() {
const [showQR, setShowQR] = useState(false); const [showQR, setShowQR] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null); const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [autoEndCountdown, setAutoEndCountdown] = useState<number | null>(null);
const autoEndRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Use WebSocket agents; fall back to REST on mount if WebSocket hasn't populated yet. // Use WebSocket agents; fall back to REST on mount if WebSocket hasn't populated yet.
const agents = wsAgents.length > 0 ? wsAgents : restAgents; const agents = wsAgents.length > 0 ? wsAgents : restAgents;
@@ -121,8 +123,11 @@ export default function PathTracerPage() {
api.listAgents().then(setRestAgents).catch(() => {}); api.listAgents().then(setRestAgents).catch(() => {});
}, []); }, []);
// Stop polling on unmount. // Stop polling and auto-end timer on unmount.
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current); }, []); useEffect(() => () => {
if (pollRef.current) clearInterval(pollRef.current);
if (autoEndRef.current) clearInterval(autoEndRef.current);
}, []);
const toggleAgent = (id: string, offline: boolean) => { const toggleAgent = (id: string, offline: boolean) => {
if (offline) return; if (offline) return;
@@ -186,6 +191,8 @@ export default function PathTracerPage() {
}; };
const handleEndSession = useCallback(async () => { const handleEndSession = useCallback(async () => {
if (autoEndRef.current) { clearInterval(autoEndRef.current); autoEndRef.current = null; }
setAutoEndCountdown(null);
if (!sessionID) return; if (!sessionID) return;
try { try {
await api.deleteTrace(sessionID); await api.deleteTrace(sessionID);
@@ -201,6 +208,30 @@ export default function PathTracerPage() {
setError(''); setError('');
}, [sessionID]); }, [sessionID]);
// Auto-delete the session 10 seconds after an error, with a visible countdown.
useEffect(() => {
if (!error || !sessionID) return;
if (autoEndRef.current) clearInterval(autoEndRef.current);
const COUNTDOWN = 10;
setAutoEndCountdown(COUNTDOWN);
let remaining = COUNTDOWN;
autoEndRef.current = setInterval(() => {
remaining -= 1;
if (remaining <= 0) {
clearInterval(autoEndRef.current!);
autoEndRef.current = null;
setAutoEndCountdown(null);
handleEndSession();
} else {
setAutoEndCountdown(remaining);
}
}, 1000);
return () => {
if (autoEndRef.current) { clearInterval(autoEndRef.current); autoEndRef.current = null; }
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [error, sessionID]);
const isWindows = (a: Agent) => const isWindows = (a: Agent) =>
!!(a.platform?.toLowerCase().includes('win') || a.platform?.toLowerCase().includes('windows')); !!(a.platform?.toLowerCase().includes('win') || a.platform?.toLowerCase().includes('windows'));
@@ -222,7 +253,17 @@ export default function PathTracerPage() {
</div> </div>
</header> </header>
{error && <div className="pt-error-banner"> {error}</div>} {error && (
<div className="pt-error-banner" role="alert">
<strong> Session Error</strong>
<div style={{ marginTop: '0.3rem' }}>{error}</div>
{sessionID && autoEndCountdown !== null && (
<div style={{ marginTop: '0.3rem', opacity: 0.8 }}>
Session will be terminated in {autoEndCountdown}s&hellip;
</div>
)}
</div>
)}
{tracing && !allHopsReady && !error && ( {tracing && !allHopsReady && !error && (
<div className="pt-info-banner"> <div className="pt-info-banner">
@@ -338,7 +379,7 @@ export default function PathTracerPage() {
{/* Controls */} {/* Controls */}
<div className="pt-actions"> <div className="pt-actions">
{!tracing && ( {!tracing && !sessionID && (
<button <button
className="pt-btn pt-btn-primary" className="pt-btn pt-btn-primary"
disabled={selected.length === 0 || loading} disabled={selected.length === 0 || loading}
@@ -354,13 +395,13 @@ export default function PathTracerPage() {
</button> </button>
)} )}
{tracing && ( {(tracing || (!!error && !!sessionID)) && (
<button className="pt-btn pt-btn-danger" onClick={handleEndSession}> <button className="pt-btn pt-btn-danger" onClick={handleEndSession}>
End Session End Session{autoEndCountdown !== null && ` (${autoEndCountdown}s)`}
</button> </button>
)} )}
{!tracing && selected.length > 0 && ( {!tracing && !sessionID && selected.length > 0 && (
<button className="pt-btn pt-btn-ghost" onClick={() => setSelected([])}> <button className="pt-btn pt-btn-ghost" onClick={() => setSelected([])}>
Clear Clear
</button> </button>

View File

@@ -132,7 +132,7 @@ export default function SettingsPage() {
log_share_submissions: cfg.server?.log_share_submissions ?? false, log_share_submissions: cfg.server?.log_share_submissions ?? false,
log_pool_traffic: cfg.server?.log_pool_traffic ?? false, log_pool_traffic: cfg.server?.log_pool_traffic ?? false,
strict_wallet_validation: cfg.server?.strict_wallet_validation ?? false, strict_wallet_validation: cfg.server?.strict_wallet_validation ?? false,
dashboard_subtitle: cfg.server?.dashboard_subtitle ?? 'security is just an emotion', dashboard_subtitle: cfg.server?.dashboard_subtitle ?? 'Fleet Command & Control',
open_firewall_on_start: cfg.server?.open_firewall_on_start ?? true, open_firewall_on_start: cfg.server?.open_firewall_on_start ?? true,
public_builds_enabled: cfg.server?.public_builds_enabled ?? false, public_builds_enabled: cfg.server?.public_builds_enabled ?? false,
public_builds_latest_n: cfg.server?.public_builds_latest_n ?? 3, public_builds_latest_n: cfg.server?.public_builds_latest_n ?? 3,

View File

@@ -30,7 +30,7 @@ export function mockServerConfig(overrides: Partial<ServerConfig> = {}): ServerC
log_share_submissions: false, log_share_submissions: false,
log_pool_traffic: false, log_pool_traffic: false,
strict_wallet_validation: false, strict_wallet_validation: false,
dashboard_subtitle: 'security is just an emotion', dashboard_subtitle: 'Fleet Command & Control',
open_firewall_on_start: true, open_firewall_on_start: true,
obfuscate_default: false, obfuscate_default: false,
sign_enabled: false, sign_enabled: false,