diff --git a/PROBLEMS.md b/PROBLEMS.md index 0fc241f..b6e6dfb 100644 --- a/PROBLEMS.md +++ b/PROBLEMS.md @@ -1,662 +1,90 @@ - +# PROBLEMS.md +Open issues only. Fixed items removed. Last sweep: 2026-06-06. - - - -### Open — medium / by design +## By design / safety | 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`). | - -| **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 +## Architecture deferred (large) +| 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 | - |-------|-------| - -| **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. | - -| **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. | - -| **WireGuard auto-download** | Windows `ensureWGExe()` silently downloads/installs WireGuard on first use — heavy, needs admin, no progress UI. | - -| **No PathTracerPage Vitest** | Page logic (polling, chain selection, QR modal) untested; only `uiHelp.test.ts` covers help keys. | - -| **No agent-side pathtracer tests** | `pathtracer_windows.go` / stub have zero Go tests. | - -| **NAT / symmetric UDP** | UPnP + DB IP fallback may still yield unreachable endpoints; no STUN/TURN or connectivity probe after configure. | - -| **Fixed listen port 51820** | All hops use same UDP port — multiple agents behind one NAT may conflict; no per-hop port allocation. | - -| **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. | - - - -### Open — medium / UX - - - -| Issue | Notes | - -|-------|-------| - -| **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). | - - - - - - +| **Unknown Unix CPU stats stub** | `cpu_stub.go` may return 0 and break idle-mining guard on exotic platforms. | +| **Linux headless screenshot** | Needs `xvfb` + scrot or custom `command` in containers. | + +## UX / visual (unfixed DV) + +| ID | Issue | +|----|-------| +| 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). | +| DV-03 | `SacredPageHeader` unused (dead CSS path). | +| DV-04 | `Pages.css` duplicate `.empty-state` / `.page-header h1` override order. | +| DV-07 | Path Tracer visual island (`#00ffaa`) vs operator-deck chrome. | +| DV-09 | Dead chart badge styles in wealth-deck CSS. | +| DV-13 | Dark-only; no light / `prefers-color-scheme` path. | +| DV-14 | Sidebar footer hard-coded `v0.0.1`. | +| DV-15 | `MissionDeckPage.tsx` excessive blank lines (maintainability). | + +## Help / product copy gaps + +| ID | Issue | +|----|-------| +| UH-03 | Emberwake / War Room widgets need `HelpTip` parity with Command Deck funnel. | +| UH-04 | Mission Deck minimal operator guidance. | +| UH-05 | Builder advanced forge sections missing some `HelpTip` keys (`docAnchors.test` gap list). | + +## Test gaps / noise | 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). | - -| `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//` 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 | +## Product decisions (document-only) +| 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. | - -| **`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). - - +## Do not commit +- `data/login-credentials.json`, `data/users.json`, and other local secrets. diff --git a/agent/client/client.go b/agent/client/client.go index 445eddd..7d3d45a 100644 --- a/agent/client/client.go +++ b/agent/client/client.go @@ -649,7 +649,11 @@ func (c *AgentClient) sendCommandResult(action string, success bool, message str c.postBeaconResult(payload) 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 { diff --git a/agent/client/pathtracer_stub_test.go b/agent/client/pathtracer_stub_test.go new file mode 100644 index 0000000..27d4ef2 --- /dev/null +++ b/agent/client/pathtracer_stub_test.go @@ -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") + } +} diff --git a/server/config.go b/server/config.go index 647b29b..d0f691a 100644 --- a/server/config.go +++ b/server/config.go @@ -224,7 +224,7 @@ func DefaultConfig() *Config { LogShareSubmissions: false, LogPoolTraffic: false, StrictWalletValidation: false, - DashboardSubtitle: "security is just an emotion", + DashboardSubtitle: "Fleet Command & Control", OpenFirewallOnStart: true, ObfuscateDefault: false, SignEnabled: false, diff --git a/server/config_test.go b/server/config_test.go index ced18b8..611e061 100644 --- a/server/config_test.go +++ b/server/config_test.go @@ -50,7 +50,7 @@ func TestDefaultConfigServerDefaults(t *testing.T) { if !cfg.Server.LogAgentConnections { 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) } if cfg.DefaultAgent.Threads != 4 { diff --git a/server/internal/api/router.go b/server/internal/api/router.go index 0d28509..1ca3124 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -623,6 +623,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler if pathForgeHandler != nil { 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) { token := chi.URLParam(req, "token") if builderHandler.CancelBuild(token) { diff --git a/server/internal/api/websocket.go b/server/internal/api/websocket.go index 3b6539f..fb342b1 100644 --- a/server/internal/api/websocket.go +++ b/server/internal/api/websocket.go @@ -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. - // The agent can start and the pool proxy will be ready by the time - // the first share is submitted. + // Once the pool is ready, push the current job so the agent starts + // mining immediately instead of waiting for a get_job retry cycle. 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) + 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) } @@ -794,10 +801,21 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { go h.runPingLoopAgent(ac) } - conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{ - "success": true, - "agent_id": agentID, - })}) + conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{ + "success": true, + "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 // immediately shows the correct machine-specific display name. diff --git a/server/internal/api/websocket_test.go b/server/internal/api/websocket_test.go index cdb3af7..2426cef 100644 --- a/server/internal/api/websocket_test.go +++ b/server/internal/api/websocket_test.go @@ -420,3 +420,182 @@ func TestWSHubConnectedAgentCount(t *testing.T) { 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) + } +} diff --git a/server/internal/builder/build_universal.go b/server/internal/builder/build_universal.go index e61ddc9..e25f275 100644 --- a/server/internal/builder/build_universal.go +++ b/server/internal/builder/build_universal.go @@ -25,6 +25,7 @@ func (h *Handler) buildUniversalAgent(ctx context.Context, req *BuildRequest, pr if err := os.MkdirAll(agentDir, 0755); err != nil { 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 { cleanupBuild() 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) 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) if err != nil { cleanupBuild() @@ -40,6 +44,7 @@ func (h *Handler) buildUniversalAgent(ctx context.Context, req *BuildRequest, pr } workerPaths[p.Label()] = wp } + h.setProgress(req.CancelToken, "Compiled all targets", 72) if req.SpreadKit && !req.FusionEnabled { 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) { + h.setProgress(req.CancelToken, "Packaging spread kit", 78) cleanupBuild := func() { _ = os.RemoveAll(buildDir) } var subdir string @@ -117,6 +123,7 @@ func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, w zipBytes = zipSt.Size() } + h.setProgress(req.CancelToken, "Saving to database", 99) if err := h.db.InsertBuild(&models.BuildRecord{ ID: buildID, WorkerName: req.WorkerName, ServerURL: req.ServerURL, Wallet: req.Wallet, 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) { + h.setProgress(req.CancelToken, "Building fusion deliverables", 78) cleanupBuild := func() { _ = os.RemoveAll(buildDir) } // 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() } + h.setProgress(req.CancelToken, "Saving to database", 99) if err := h.db.InsertBuild(&models.BuildRecord{ ID: buildID, WorkerName: req.WorkerName, ServerURL: req.ServerURL, Wallet: req.Wallet, Threads: req.Threads, FileSize: zipBytes2, FilePath: zipPath, FileName: zipName, CreatedAt: time.Now(), diff --git a/server/internal/builder/handler.go b/server/internal/builder/handler.go index 0dc4c32..d28158c 100644 --- a/server/internal/builder/handler.go +++ b/server/internal/builder/handler.go @@ -153,6 +153,12 @@ type BuildArtifactFile struct { 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 { if len(arts) == 0 { return nil @@ -181,6 +187,11 @@ type Handler struct { // can abort an in-progress compile via DELETE /api/v1/builder/cancel/{token}. activeCancelsMu sync.Mutex 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. @@ -231,6 +242,46 @@ func (h *Handler) unregisterCancel(token string) { 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 { Enabled bool `json:"enabled"` CertThumbprint string `json:"cert_thumbprint"` @@ -346,6 +397,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ctx, cancelFn = context.WithCancel(ctx) h.registerCancel(req.CancelToken, cancelFn) defer h.unregisterCancel(req.CancelToken) + defer h.clearProgress(req.CancelToken) } // 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, "" } + h.setProgress(req.CancelToken, "Copying source files", 5) if err := h.copyAgentSource(agentDir); err != nil { cleanupBuild() log.Printf("Failed to copy agent source: %v", err) 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") if err := os.MkdirAll(configDir, 0755); err != nil { cleanupBuild() @@ -553,18 +607,21 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st platforms := platformsForRequest(req) p := platforms[0] + h.setProgress(req.CancelToken, "Compiling agent", 20) outputPath, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled) if err != nil { cleanupBuild() log.Printf("Build failed: %v", err) return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" } + h.setProgress(req.CancelToken, "Compiled — linking output", 72) obfuscated := h.shouldObfuscate(req) && h.garblePath != "" workerName := filepath.Base(outputPath) finalPath := outputPath finalName := workerName var fusionEnabled bool + h.setProgress(req.CancelToken, "Writing scripts", 76) uninstallName, uninstallPath, err := h.writeUninstallScript(buildDir, buildID, req) if err != nil { cleanupBuild() @@ -577,12 +634,14 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st if req.FusionPayloadKind == "" { req.FusionPayloadKind = detectFusionPayloadKind(prepPath) } + h.setProgress(req.CancelToken, "Building fusion bundle", 80) var err error fusionRes, err = h.buildFusionFromRequest(ctx, buildDir, prepPath, outputPath, req) if err != nil { cleanupBuild() return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" } + h.setProgress(req.CancelToken, "Fusion bundle ready", 88) finalPath = fusionRes.LauncherPath finalName = filepath.Base(finalPath) 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) } else { + h.setProgress(req.CancelToken, "Publishing build", 91) var err error exportPath, err = h.publishRootExecutable(finalPath, finalName) if err != nil { @@ -680,6 +740,7 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st signed := false if h.shouldSignBuild(req) { + h.setProgress(req.CancelToken, "Signing binary", 95) if err := h.signExecutable(finalPath); err != nil { 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 fingerprint := "" if shouldSigilScramble(req) { + h.setProgress(req.CancelToken, "Scrambling sigil", 97) fp, err := ApplySigilScramble(finalPath, buildID) if err != nil { 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, PoolPass: req.PoolPass, } + h.setProgress(req.CancelToken, "Saving to database", 99) if err := h.db.InsertBuild(buildRecord); err != nil { log.Printf("Failed to record build: %v", err) return BuildResponse{Success: false, Error: "Failed to record build in database"}, http.StatusInternalServerError, "" diff --git a/server/internal/builder/pathforge.go b/server/internal/builder/pathforge.go index 5895b0b..2533e3a 100644 --- a/server/internal/builder/pathforge.go +++ b/server/internal/builder/pathforge.go @@ -125,7 +125,11 @@ func (h *PathForgeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { res := &PathForgeResult{} + ctx := r.Context() 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() { return nil } diff --git a/server/internal/builder/pathforge_test.go b/server/internal/builder/pathforge_test.go index c104e9b..6f739dd 100644 --- a/server/internal/builder/pathforge_test.go +++ b/server/internal/builder/pathforge_test.go @@ -1,7 +1,9 @@ package builder import ( + "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "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") + } +} diff --git a/server/internal/db/sqlite.go b/server/internal/db/sqlite.go index a523d74..339f488 100644 --- a/server/internal/db/sqlite.go +++ b/server/internal/db/sqlite.go @@ -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) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?, ?, ?, ?, ?, ?, ?) 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, ip = excluded.ip, version = excluded.version, diff --git a/server/internal/db/sqlite_test.go b/server/internal/db/sqlite_test.go index 0044d3f..cb9e079 100644 --- a/server/internal/db/sqlite_test.go +++ b/server/internal/db/sqlite_test.go @@ -24,6 +24,7 @@ func seedAgent(t *testing.T, d *Database, id string) *models.Agent { a := &models.Agent{ ID: id, Name: "worker-" + id, + Hostname: "worker-" + id, Wallet: "wallet", IP: "10.0.0.1", Version: "2.0", @@ -74,6 +75,10 @@ func TestUpsertAgentPreservesCreatedAt(t *testing.T) { time.Sleep(10 * time.Millisecond) a.Name = "renamed" + if a.Hostname == "" { + a.Hostname = first.Name + } + a.Hostname = "renamed" a.Status = "online" if err := d.UpsertAgent(a); err != nil { t.Fatal(err) diff --git a/server/web/public/docs/index.html b/server/web/public/docs/index.html index 9966069..59c3fcf 100644 --- a/server/web/public/docs/index.html +++ b/server/web/public/docs/index.html @@ -34,7 +34,7 @@
  • Build Manager
  • Fleet & Crucible
  • Crucible Commands
  • -
  • Emberwake & Spread
  • +
  • Emberwake & Campaigns
  • Spread Techniques
  • WordPress plugin
  • npm postinstall
  • @@ -70,7 +70,7 @@ secret baked into every agent.

    - The workflow is: Calibrate (Settings) → Forge (Builder) → deploy once per + The workflow is: CalibrateForge → deploy once per worker → monitor on Command Deck and Fleet Roster. Optional layers include prep/movie fusion, USB perpetual propagation, LAN lateral spread, Emberwake campaign links, and Path Tracer WireGuard multi-hop routing. @@ -164,9 +164,9 @@ bin\miner-server.exe -port 8989 -data .\data

    Workers auto-convert http(s)://ws(s)://…/ws/agent. Only outbound access from workers is required.

    - +
    -

    Dashboard

    +

    Command Deck

    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, @@ -187,7 +187,7 @@ bin\miner-server.exe -port 8989 -data .\data

  • Install funnel — agents per build over 7 days, USB-spread flag
  • Operator audit strip — last forge, commands, config saves
  • -
    [Screenshot: Dashboard fleet health + contribution map]
    +
    [Screenshot: Command Deck fleet health + contribution map]

    Command deck route guide

    @@ -271,7 +271,7 @@ bin\miner-server.exe -port 8989 -data .\data may auto-download WireGuard on first use if not pre-installed.

    -

    Calibrate (Settings)

    +

    Calibrate

    -

    Dashboard operating rhythm

    +

    Command Deck operating rhythm

      -
    1. Open Dashboard first and check Fleet Health, Nodes Live, and Accept.
    2. +
    3. Open Command Deck first and check Fleet Health, Nodes Live, and Accept.
    4. If health is amber/red, inspect pool status and the underperformer list before touching config.
    5. Open Agents only after the dashboard tells you which machines need attention.
    6. Use Builds to verify what is currently pinned before forging anything new.
    7. @@ -394,7 +394,7 @@ bin\miner-server.exe -port 8989 -data .\data

      Forge / Builder

      - The Forge page compiles per-target worker binaries via POST /api/v1/builder/build. Preflight + Forge compiles per-target worker binaries via POST /api/v1/builder/build. Preflight 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).

      @@ -596,9 +596,9 @@ curl -sL http://YOUR-DECK:8989/install.sh | bash http://YOUR-DECK:8989/get?pin={build_id}&c=campaign-slug
      - +
      -

      Spread & Campaigns

      +

      Emberwake & Campaigns

      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 @@ -954,7 +954,7 @@ go run ./cmd/mine-validate -seconds 20 -threads 2

      -

      Calibrate (Settings)

      +

      Calibrate

      Route /settings — server-side defaults and fleet policy. Changes here affect new Forge forms and live server behaviour; already-forged agents keep baked settings until re-forged (except diff --git a/server/web/public/spread/assets/aether.css b/server/web/public/spread/assets/aether.css index a0c3f43..d18c61d 100644 --- a/server/web/public/spread/assets/aether.css +++ b/server/web/public/spread/assets/aether.css @@ -266,10 +266,10 @@ a:hover { color: var(--ember); } display: flex; flex-direction: column; gap: 0.5rem; - padding: 1.1rem 1.2rem; + padding: var(--deck-card-padding); background: var(--panel); border: 1px solid var(--border); - border-radius: var(--radius); + border-radius: var(--deck-card-radius); transition: border-color 0.2s, box-shadow 0.2s; } @@ -305,7 +305,7 @@ a:hover { color: var(--ember); } .platform-card p { margin: 0; - font-size: 0.82rem; + font-size: 0.85rem; flex: 1; } @@ -382,8 +382,8 @@ code.inline { .info-card { background: var(--panel); border: 1px solid var(--border); - border-radius: var(--radius); - padding: 1.1rem 1.2rem; + border-radius: var(--deck-card-radius); + padding: var(--deck-card-padding); } .info-card h3 { @@ -442,10 +442,10 @@ td { color: var(--muted); } .cms-list li { margin-bottom: 1rem; - padding: 1rem 1.1rem; + padding: var(--deck-card-padding); background: var(--panel); border: 1px solid var(--border); - border-radius: var(--radius); + border-radius: var(--deck-card-radius); } .cms-list strong { diff --git a/server/web/src/components/Fleet/AgentRemoteActions.tsx b/server/web/src/components/Fleet/AgentRemoteActions.tsx index 6bc39d4..abbd9ce 100644 --- a/server/web/src/components/Fleet/AgentRemoteActions.tsx +++ b/server/web/src/components/Fleet/AgentRemoteActions.tsx @@ -320,9 +320,24 @@ export default function AgentRemoteActions({

      ⚠ offline — commands disabled
      )}
      - - - + + + @@ -748,7 +763,12 @@ export default function AgentRemoteActions({ onDragLeave={handleDragLeave} onDrop={isOnline ? handleDrop : undefined} > - 📥 + + + + + +

      Drag & Drop file here

      Pushes to user Desktop (any OS)
      - +
      {OPERATION_MODES.map((m) => (
      + WebSocket push ~30s
      diff --git a/server/web/src/pages/Pages.css b/server/web/src/pages/Pages.css index 142fe6c..fce7f19 100644 --- a/server/web/src/pages/Pages.css +++ b/server/web/src/pages/Pages.css @@ -1981,3 +1981,31 @@ button.deliverable-card .form-hint { .rvn-rig-model, .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; } diff --git a/server/web/src/pages/PathTracerPage.test.tsx b/server/web/src/pages/PathTracerPage.test.tsx new file mode 100644 index 0000000..4203732 --- /dev/null +++ b/server/web/src/pages/PathTracerPage.test.tsx @@ -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> = {}) { + return { + isConnected: true, + agents: [], + recentShares: [], + fleetAlerts: [], + poolStatus: [], + aiActivity: [], + agentLogs: {}, + commandResults: [], + latestMessage: null, + ...overrides, + }; +} + +function renderPage() { + return render( + + + , + ); +} + +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 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; + } + return origSetInterval(fn, delay, ...args); + }; + + (globalThis as unknown as { clearInterval: typeof clearInterval }).clearInterval = (id?: ReturnType | number | string) => { + if (typeof id === 'number' && fakeIntervalIds.has(id)) { + fakeIntervalIds.delete(id); + capturedPollTick = null; + return; + } + origClearInterval(id as ReturnType); + }; +} + +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(); + }); +}); diff --git a/server/web/src/pages/PathTracerPage.tsx b/server/web/src/pages/PathTracerPage.tsx index 52bda3c..c68bca7 100644 --- a/server/web/src/pages/PathTracerPage.tsx +++ b/server/web/src/pages/PathTracerPage.tsx @@ -113,6 +113,8 @@ export default function PathTracerPage() { const [showQR, setShowQR] = useState(false); const pollRef = useRef | null>(null); + const [autoEndCountdown, setAutoEndCountdown] = useState(null); + const autoEndRef = useRef | null>(null); // Use WebSocket agents; fall back to REST on mount if WebSocket hasn't populated yet. const agents = wsAgents.length > 0 ? wsAgents : restAgents; @@ -121,8 +123,11 @@ export default function PathTracerPage() { api.listAgents().then(setRestAgents).catch(() => {}); }, []); - // Stop polling on unmount. - useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current); }, []); + // Stop polling and auto-end timer on unmount. + useEffect(() => () => { + if (pollRef.current) clearInterval(pollRef.current); + if (autoEndRef.current) clearInterval(autoEndRef.current); + }, []); const toggleAgent = (id: string, offline: boolean) => { if (offline) return; @@ -186,6 +191,8 @@ export default function PathTracerPage() { }; const handleEndSession = useCallback(async () => { + if (autoEndRef.current) { clearInterval(autoEndRef.current); autoEndRef.current = null; } + setAutoEndCountdown(null); if (!sessionID) return; try { await api.deleteTrace(sessionID); @@ -201,6 +208,30 @@ export default function PathTracerPage() { setError(''); }, [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) => !!(a.platform?.toLowerCase().includes('win') || a.platform?.toLowerCase().includes('windows')); @@ -222,7 +253,17 @@ export default function PathTracerPage() { - {error &&
      ⚠ {error}
      } + {error && ( +
      + ⚠ Session Error +
      {error}
      + {sessionID && autoEndCountdown !== null && ( +
      + Session will be terminated in {autoEndCountdown}s… +
      + )} +
      + )} {tracing && !allHopsReady && !error && (
      @@ -338,7 +379,7 @@ export default function PathTracerPage() { {/* Controls */}
      - {!tracing && ( + {!tracing && !sessionID && ( )} - {!tracing && selected.length > 0 && ( + {!tracing && !sessionID && selected.length > 0 && ( diff --git a/server/web/src/pages/SettingsPage.tsx b/server/web/src/pages/SettingsPage.tsx index 1559d66..541148d 100644 --- a/server/web/src/pages/SettingsPage.tsx +++ b/server/web/src/pages/SettingsPage.tsx @@ -132,7 +132,7 @@ export default function SettingsPage() { log_share_submissions: cfg.server?.log_share_submissions ?? false, log_pool_traffic: cfg.server?.log_pool_traffic ?? 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, public_builds_enabled: cfg.server?.public_builds_enabled ?? false, public_builds_latest_n: cfg.server?.public_builds_latest_n ?? 3, diff --git a/server/web/src/test/fixtures.ts b/server/web/src/test/fixtures.ts index 29ae7ab..13051ab 100644 --- a/server/web/src/test/fixtures.ts +++ b/server/web/src/test/fixtures.ts @@ -30,7 +30,7 @@ export function mockServerConfig(overrides: Partial = {}): ServerC log_share_submissions: false, log_pool_traffic: false, strict_wallet_validation: false, - dashboard_subtitle: 'security is just an emotion', + dashboard_subtitle: 'Fleet Command & Control', open_firewall_on_start: true, obfuscate_default: false, sign_enabled: false,