From 1479e161ab4817fa04456164bb406002c3f010ba Mon Sep 17 00:00:00 2001 From: Shawn Date: Sat, 4 Jul 2026 03:53:30 -0400 Subject: [PATCH] =?UTF-8?q?build:usb-image=20=E2=80=94=20Final=20USB=20ima?= =?UTF-8?q?ge=20assembly=20script,=20Ventoy=20config,=20and=20Hermes=20age?= =?UTF-8?q?nt=20runtime=20structure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build/usb-image.sh: Main USB image assembler with artifact validation, ISO building, disk image creation, and USB write support - build/ventoy.json: Ventoy configuration for multi-boot USB - src/hermes/config.yaml: Portable Hermes agent configuration - src/hermes/autorun.sh: Auto-launch script with full diagnostic pipeline - Makefile: Build system with iso/image/check/clean/write targets Generates fallback stubs for any diagnostic modules whose upstream build tasks haven't completed yet, ensuring the image boots even during active development. --- Makefile | 135 +++ build/usb-image.sh | 804 ++++++++++++++ build/ventoy.json | 25 + docs/architecture.md | 578 ++++++++++ src/build-hermes-runtime.sh | 339 ++++++ src/diagnostics/__init__.py | 28 + .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 1008 bytes .../__pycache__/hardware.cpython-311.pyc | Bin 0 -> 33810 bytes src/diagnostics/hardware.py | 583 ++++++++++ src/diagnostics/stress.py | 995 ++++++++++++++++++ src/hermes/autorun.sh | 250 +++++ src/hermes/config.yaml | 94 ++ src/hermes/launch.ps1 | 39 + src/hermes/launch.sh | 63 ++ src/hermes/tool-manifest.json | 80 ++ 15 files changed, 4013 insertions(+) create mode 100644 Makefile create mode 100755 build/usb-image.sh create mode 100644 build/ventoy.json create mode 100644 docs/architecture.md create mode 100644 src/build-hermes-runtime.sh create mode 100644 src/diagnostics/__init__.py create mode 100644 src/diagnostics/__pycache__/__init__.cpython-311.pyc create mode 100644 src/diagnostics/__pycache__/hardware.cpython-311.pyc create mode 100644 src/diagnostics/hardware.py create mode 100644 src/diagnostics/stress.py create mode 100755 src/hermes/autorun.sh create mode 100644 src/hermes/config.yaml create mode 100644 src/hermes/launch.ps1 create mode 100644 src/hermes/launch.sh create mode 100644 src/hermes/tool-manifest.json diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1d6a3cb --- /dev/null +++ b/Makefile @@ -0,0 +1,135 @@ +# Hermes Portable Rescue — Build System +# ========================================= +# +# Targets: +# all — Full build: ISO + disk image (default) +# iso — Build rescue ISO only +# image — Build full disk image (requires ISO first) +# check — Validate all upstream artifacts are present +# clean — Remove all build artifacts +# distclean — Clean + remove downloaded tool binaries +# write — Write image to USB device (run: make write DEV=/dev/sdX) +# ventoy — Install Ventoy to USB device (run: make ventoy DEV=/dev/sdX) +# help — Show this message +# +# Quick Start: +# 1. make check — See what's missing +# 2. make all — Build everything that's available +# 3. make write DEV=/dev/sdb — Write to USB (CAUTION!) +# + +BUILD_DIR ?= build +HERMES_SRC ?= src/hermes +DIAG_SRC ?= src/diagnostics +TOOLS_SRC ?= src/tools +OUTPUT_DIR ?= $(BUILD_DIR) + +IMAGE_SCRIPT := $(BUILD_DIR)/usb-image.sh +OUTPUT_IMAGE := $(OUTPUT_DIR)/hermes-rescue.img +OUTPUT_ISO := $(OUTPUT_DIR)/hermes-rescue.iso + +DEVICE ?= /dev/sdb + +# Colors +BOLD := \033[1m +GREEN := \033[0;32m +YELLOW := \033[1;33m +RED := \033[0;31m +NC := \033[0m + +.PHONY: all iso image check clean distclean write ventoy help dirs + +all: check iso image + @echo "" + @printf "$(GREEN)✓ Build complete!$(NC)\n" + @echo " ISO: $(OUTPUT_ISO)" + @echo " IMG: $(OUTPUT_IMAGE)" + +iso: dirs + @printf "$(BOLD)Building rescue ISO...$(NC)\n" + @bash $(IMAGE_SCRIPT) --iso-only + +image: dirs iso + @printf "$(BOLD)Building disk image...$(NC)\n" + @bash $(IMAGE_SCRIPT) + +dirs: + @mkdir -p $(BUILD_DIR) + +check: + @printf "$(BOLD)Checking upstream artifacts...$(NC)\n" + @bash $(IMAGE_SCRIPT) --skip-validate 2>&1 | head -30 || true + @echo "" + @printf "Expected artifact paths:\n" + @for f in \ + "$(HERMES_SRC)/agent/hermes" \ + "$(HERMES_SRC)/config.yaml" \ + "$(HERMES_SRC)/autorun.sh" \ + "$(DIAG_SRC)/hardware.py" \ + "$(DIAG_SRC)/bsod.py" \ + "$(DIAG_SRC)/drivers.py" \ + "$(DIAG_SRC)/backup.py" \ + "$(DIAG_SRC)/stress.py" \ + ; do \ + if [ -f "$$f" ] || [ -d "$$f" ]; then \ + printf " $(GREEN)✓$(NC) %s\n" "$$f"; \ + else \ + printf " $(YELLOW)✗$(NC) %s\n" "$$f"; \ + fi; \ + done + +clean: + @printf "$(BOLD)Cleaning build artifacts...$(NC)\n" + @rm -rf $(BUILD_DIR)/usb-staging/ + @rm -f $(OUTPUT_IMAGE) $(OUTPUT_ISO) + @printf "$(GREEN)✓ Cleaned.$(NC)\n" + +distclean: clean + @printf "$(BOLD)Deep cleaning...$(NC)\n" + @rm -rf $(TOOLS_SRC)/* + @printf "$(GREEN)✓ Deep clean complete.$(NC)\n" + +write: + @if [ ! -b "$(DEVICE)" ]; then \ + printf "$(RED)Error: $(DEVICE) is not a block device. Specify DEV=/dev/sdX.$(NC)\n"; \ + exit 1; \ + fi + @printf "$(YELLOW)⚠️ About to write to $(DEVICE). ALL DATA WILL BE DESTROYED!$(NC)\n" + @printf "Type the device path to confirm: " && read confirm && [ "$$confirm" = "$(DEVICE)" ] + @printf "$(BOLD)Writing $(OUTPUT_IMAGE) to $(DEVICE)...$(NC)\n" + @sudo dd if=$(OUTPUT_IMAGE) of=$(DEVICE) bs=4M status=progress conv=fsync + @sync + @printf "$(GREEN)✓ Written to $(DEVICE).$(NC)\n" + +ventoy: + @if [ ! -b "$(DEVICE)" ]; then \ + printf "$(RED)Error: $(DEVICE) is not a block device. Specify DEV=/dev/sdX.$(NC)\n"; \ + exit 1; \ + fi + @if ! command -v Ventoy2Disk.sh &>/dev/null; then \ + printf "$(RED)Ventoy2Disk.sh not found. Install Ventoy first.$(NC)\n"; \ + exit 1; \ + fi + @printf "$(YELLOW)⚠️ About to install Ventoy to $(DEVICE). ALL DATA WILL BE DESTROYED!$(NC)\n" + @printf "Type the device path to confirm: " && read confirm && [ "$$confirm" = "$(DEVICE)" ] + @sudo Ventoy2Disk.sh -i -g "$(DEVICE)" + @printf "$(GREEN)✓ Ventoy installed to $(DEVICE). Copy ISOs to the data partition.$(NC)\n" + +help: + @printf "$(BOLD)Hermes Portable Rescue — Build Targets$(NC)\n" + @echo "" + @sed -n 's/^# //p; s/^#//p' $(MAKEFILE_LIST) | head -20 + @echo "" + @echo "Usage: make [VAR=value]" + @echo "" + @echo "Variables:" + @echo " DEVICE=/dev/sdX — Target USB device (for write/ventoy targets)" + @echo " BUILD_DIR=build — Output directory" + @echo " HERMES_SRC=src/hermes — Hermes runtime source" + @echo " DIAG_SRC=src/diagnostics — Diagnostic scripts source" + @echo " TOOLS_SRC=src/tools — Diagnostic tools source" + @echo "" + @echo "Examples:" + @echo " make — Full build (check → iso → image)" + @echo " make iso — Build ISO only" + @echo " make write DEV=/dev/sdb — Write image to USB" diff --git a/build/usb-image.sh b/build/usb-image.sh new file mode 100755 index 0000000..a62bd76 --- /dev/null +++ b/build/usb-image.sh @@ -0,0 +1,804 @@ +#!/usr/bin/env bash +# ============================================================================== +# Hermes Portable Rescue — USB Image Assembler +# ============================================================================== +# +# Builds the final USB boot image from upstream build artifacts. +# +# Usage: +# ./build/usb-image.sh [options] +# +# Options: +# --output PATH Output path for the disk image (default: build/hermes-rescue.img) +# --iso-only Build only the ISO, not the full disk image +# --write-device DEV Write directly to a USB device (e.g. /dev/sdb) +# --force Skip all safety confirmations +# --skip-validate Skip artifact validation (for development) +# --verbose Verbose output +# --clean Clean build dirs before starting +# +# Dependencies: +# - build:hermes-runtime → src/hermes/ (Hermes agent runtime) +# - build:hardware-inventory → src/diagnostics/hardware.py +# - build:bsod-analyzer → src/diagnostics/bsod.py +# - build:driver-updater → src/diagnostics/drivers.py +# - build:backup-restore → src/diagnostics/backup.py +# - build:stress-tests → src/diagnostics/stress.py +# - research:boot-env → research/boot-env.md (defines the boot environment) +# +# Output: +# - build/hermes-rescue.img — Full disk image (Ventoy + Rescue ISO + Tools) +# - build/hermes-rescue.iso — Bootable rescue ISO +# +# ============================================================================== + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +# ─── Configuration ──────────────────────────────────────────────────────────── + +# These can be overridden via environment variables +OUTPUT_IMAGE="${OUTPUT_IMAGE:-"$PROJECT_DIR/build/hermes-rescue.img"}" +OUTPUT_ISO="${OUTPUT_ISO:-"$PROJECT_DIR/build/hermes-rescue.iso"}" +BUILD_DIR="${BUILD_DIR:-"$PROJECT_DIR/build/usb-staging"}" +HERMES_SRC="${HERMES_SRC:-"$PROJECT_DIR/src/hermes"}" +DIAG_SRC="${DIAG_SRC:-"$PROJECT_DIR/src/diagnostics"}" +TOOLS_SRC="${TOOLS_SRC:-"$PROJECT_DIR/src/tools"}" +VENTOY_JSON="${VENTOY_JSON:-"$PROJECT_DIR/build/ventoy.json"}" + +# Expected artifact paths (upstream deliverables) +declare -A ARTIFACTS=( + ["Hermes runtime"]="$HERMES_SRC/agent/hermes" + ["Hermes config"]="$HERMES_SRC/config.yaml" + ["Autorun script"]="$HERMES_SRC/autorun.sh" + ["Hardware inventory"]="$DIAG_SRC/hardware.py" + ["BSOD analyzer"]="$DIAG_SRC/bsod.py" + ["Driver updater"]="$DIAG_SRC/drivers.py" + ["Backup/restore"]="$DIAG_SRC/backup.py" + ["Stress tests"]="$DIAG_SRC/stress.py" +) + +# Boot environment configuration +BOOT_ENV="${BOOT_ENV:-linux}" # 'linux' or 'winpe' — set by research:boot-env +ISO_LABEL="HERMES_RESCUE" + +# Image sizes (can be overridden via env) +IMAGE_SIZE_MB="${IMAGE_SIZE_MB:-8192}" # 8 GB default +EFI_SIZE_MB="${EFI_SIZE_MB:-512}" # 512 MB EFI partition + +# ─── Colors ──────────────────────────────────────────────────────────────────── + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +info() { echo -e "${BLUE}[INFO]${NC} $*"; } +ok() { echo -e "${GREEN}[OK]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*" >&2; } +error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } +die() { error "$*"; exit 1; } + +# ─── Flags ──────────────────────────────────────────────────────────────────── + +DO_CLEAN=false +SKIP_VALIDATE=false +FORCE=false +VERBOSE=false +ISO_ONLY=false +WRITE_DEVICE="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --output) OUTPUT_IMAGE="$2"; shift 2 ;; + --iso-only) ISO_ONLY=true; shift ;; + --write-device) WRITE_DEVICE="$2"; shift 2 ;; + --force) FORCE=true; shift ;; + --skip-validate) SKIP_VALIDATE=true; shift ;; + --verbose) VERBOSE=true; shift ;; + --clean) DO_CLEAN=true; shift ;; + -h|--help) head -30 "$0"; exit 0 ;; + *) die "Unknown option: $1 (use --help)" ;; + esac +done + +# ─── Prerequisite Checks ────────────────────────────────────────────────────── + +check_command() { + if ! command -v "$1" &>/dev/null; then + die "Required command '$1' not found. Install it first." + fi + $VERBOSE && info " ✓ $1 found at $(command -v "$1")" +} + +prerequisites() { + info "Checking prerequisites..." + + # Core build tools + check_command mkisofs + check_command xorriso + check_command grub-mkrescue 2>/dev/null || check_command grub2-mkrescue + check_command dd + check_command parted + check_command mkfs.vfat + check_command mkfs.ext4 + + # Ventoy (optional — needed only for full disk image) + if ! $ISO_ONLY; then + if command -v Ventoy2Disk.sh &>/dev/null; then + VENTOY_CMD="Ventoy2Disk.sh" + elif [ -f ./ventoy/Ventoy2Disk.sh ]; then + VENTOY_CMD="./ventoy/Ventoy2Disk.sh" + else + warn "Ventoy2Disk.sh not found — will build ISO only (no Ventoy disk image)." + fi + fi + + ok "All prerequisite commands available." +} + +validate_artifacts() { + $SKIP_VALIDATE && { warn "Skipping artifact validation."; return 0; } + + info "Validating upstream build artifacts..." + local missing=0 + + for name in "${!ARTIFACTS[@]}"; do + local path="${ARTIFACTS[$name]}" + if [ -f "$path" ] || [ -d "$path" ] || [ -x "$path" ] 2>/dev/null; then + $VERBOSE && ok " Found: $name → $path" + else + warn " Missing: $name → $path" + missing=$((missing + 1)) + fi + done + + # Check boot environment research + local boot_env_doc="$PROJECT_DIR/research/boot-env.md" + if [ -f "$boot_env_doc" ]; then + $VERBOSE && ok " Found: boot environment research → $boot_env_doc" + else + warn " Missing: boot environment research → $boot_env_doc (will use default: $BOOT_ENV)" + fi + + if [ "$missing" -gt 0 ]; then + warn "Found $missing missing artifact(s). Build may be incomplete." + if ! $FORCE; then + echo "" + warn "Run with --force to continue anyway, or build upstream tasks first." + echo "" + info "Upstream tasks to complete:" + echo " build:hermes-runtime (t_c816e37d)" + echo " build:hardware-inventory (t_1215a5f0)" + echo " build:bsod-analyzer (t_10103a48)" + echo " build:driver-updater (t_2afd37ec)" + echo " build:backup-restore (t_0b33ea13)" + echo " build:stress-tests (t_f817392d)" + die "Missing artifacts. Build cannot proceed." + fi + else + ok "All upstream artifacts present." + fi +} + +# ─── Directory Setup ────────────────────────────────────────────────────────── + +setup_directories() { + info "Setting up staging directories..." + + if $DO_CLEAN && [ -d "$BUILD_DIR" ]; then + info "Cleaning existing build directory..." + rm -rf "$BUILD_DIR" + fi + + mkdir -p "$BUILD_DIR"/{ISO,hermes/{agent,tools,scripts},ventoy/theme,persistence,boot/grub} + $VERBOSE && ok "Staging directory: $BUILD_DIR" + + ok "Staging directories created." +} + +# ─── Copy Hermes Runtime ────────────────────────────────────────────────────── + +copy_hermes_runtime() { + info "Copying Hermes agent runtime..." + + if [ -d "$HERMES_SRC/agent" ]; then + cp -r "$HERMES_SRC/agent/"* "$BUILD_DIR/hermes/agent/" 2>/dev/null || \ + warn "No files in Hermes agent directory — skipping." + fi + + if [ -f "$HERMES_SRC/config.yaml" ]; then + cp "$HERMES_SRC/config.yaml" "$BUILD_DIR/hermes/config.yaml" + else + warn "config.yaml not found — generating default." + generate_default_config + fi + + if [ -f "$HERMES_SRC/autorun.sh" ]; then + cp "$HERMES_SRC/autorun.sh" "$BUILD_DIR/hermes/autorun.sh" + chmod +x "$BUILD_DIR/hermes/autorun.sh" + else + warn "autorun.sh not found — generating default." + generate_default_autorun + fi + + ok "Hermes runtime staged." +} + +# ─── Copy Diagnostic Scripts ────────────────────────────────────────────────── + +copy_diagnostics() { + info "Copying diagnostic scripts..." + + local scripts=("hardware.py" "bsod.py" "drivers.py" "backup.py" "stress.py") + for script in "${scripts[@]}"; do + local src="$DIAG_SRC/$script" + if [ -f "$src" ]; then + cp "$src" "$BUILD_DIR/hermes/scripts/$script" + chmod +x "$BUILD_DIR/hermes/scripts/$script" 2>/dev/null || true + $VERBOSE && ok " Staged: $script" + else + warn " Missing diagnostic script: $script" + # Generate a stub so the system boots but the component isn't active + generate_diagnostic_stub "$script" + fi + done + + ok "Diagnostic scripts staged." +} + +# ─── Copy Tools ──────────────────────────────────────────────────────────────── + +copy_tools() { + info "Copying diagnostic tools..." + + local tool_dirs=() + if [ -d "$TOOLS_SRC" ]; then + IFS=$'\n' read -d '' -r -a tool_dirs < <(find "$TOOLS_SRC" -mindepth 1 -maxdepth 1 -type d 2>/dev/null || true) + fi + + if [ ${#tool_dirs[@]} -eq 0 ]; then + warn "No diagnostic tools found in $TOOLS_SRC — creating structure." + mkdir -p "$BUILD_DIR/hermes/tools" + # Create placeholder README for tools that need to be added + cat > "$BUILD_DIR/hermes/tools/README.md" << 'TOOLREADME' +# Diagnostic Tools — Placeholder Directory + +Populate this directory with portable diagnostic executables: + +- `smartmontools/` — `smartctl`, `smartd` (disk health) +- `memtest86+/` — MemTest86+ binary (RAM testing) +- `stress-ng/` — stress-ng binary (CPU/GPU stress) +- `dmidecode/` — DMI table decoder (hardware enumeration) +- `lshw/` — lshw binary (hardware listing) +- `parted/` — parted, fdisk utilities +- `ntfs-3g/` — NTFS read/write support + +See the build:hermes-runtime task (t_c816e37d) for the packaging scripts. +TOOLREADME + else + for dir in "${tool_dirs[@]}"; do + local dirname + dirname="$(basename "$dir")" + cp -r "$dir" "$BUILD_DIR/hermes/tools/$dirname" + $VERBOSE && ok " Staged tools: $dirname" + done + fi + + ok "Diagnostic tools staged." +} + +# ─── Configure Boot Environment ──────────────────────────────────────────────── + +configure_boot() { + info "Configuring boot environment..." + + # Read boot environment research if available + local boot_doc="$PROJECT_DIR/research/boot-env.md" + if [ -f "$boot_doc" ]; then + # Source any boot config variables defined in the research doc + if grep -q "^BOOT_ENV=" "$boot_doc" 2>/dev/null; then + # shellcheck source=/dev/null + source "$boot_doc" + fi + info "Using boot environment from research: $BOOT_ENV" + fi + + case "$BOOT_ENV" in + linux) + configure_linux_boot + ;; + winpe) + configure_winpe_boot + ;; + hybrid) + configure_hybrid_boot + ;; + *) + die "Unknown boot environment: $BOOT_ENV" + ;; + esac +} + +configure_linux_boot() { + info "Configuring Linux-based boot environment..." + + # Generate GRUB config for the rescue ISO + cat > "$BUILD_DIR/boot/grub/grub.cfg" << 'GRUB' +set default=0 +set timeout=10 + +menuentry "Hermes Portable Rescue — Linux" { + linux /boot/vmlinuz quiet splash console=tty0 + initrd /boot/initrd.img +} + +menuentry "Hermes Rescue — Safe Mode (no HW accel)" { + linux /boot/vmlinuz nomodeset quiet + initrd /boot/initrd.img +} + +menuentry "Boot from Local Disk" { + chainloader (hd0)+1 +} + +menuentry "UEFI Firmware Settings" { + fwsetup +} + +menuentry "System Memory Test (MemTest86+)" { + linux /boot/memtest86+.bin +} +GRUB + ok "GRUB configuration written." +} + +configure_winpe_boot() { + info "Configuring WinPE-based boot environment..." + + # WinPE boot configuration + cat > "$BUILD_DIR/ISO/autounattend.xml" << 'WINAUTO' + + + + + + + Never + + + + + +WINAUTO + ok "WinPE boot configuration written." +} + +configure_hybrid_boot() { + info "Configuring hybrid (multi-boot) environment..." + # Hybrid: place both Linux ISO and WinPE ISO with Ventoy handling the boot menu + mkdir -p "$BUILD_DIR/ISO" + ok "Hybrid boot structure ready (add ISOs to build/usb-staging/ISO/)." +} + +# ─── Generate Stubs / Defaults ──────────────────────────────────────────────── + +generate_diagnostic_stub() { + local script_name="$1" + local module_name="${script_name%.py}" + + cat > "$BUILD_DIR/hermes/scripts/$script_name" << STUB +#!/usr/bin/env python3 +""" +${module_name} — STUB MODULE. + +This diagnostic component has not been built yet. +Replace this stub with the actual implementation once the +upstream build task completes. + +Upstream task reference: check the Hermes Portable Rescue board. +""" + +import sys + + +def diagnose() -> dict: + """Return a stub indicating this module is unavailable.""" + return { + "component": "${module_name}", + "status": "unavailable", + "message": "This diagnostic module has not been built yet.", + } + + +def main(): + result = diagnose() + print(f"[{result['status'].upper()}] {result['component']}: {result['message']}") + return 1 if result['status'] == 'error' else 0 + + +if __name__ == "__main__": + sys.exit(main()) +STUB + chmod +x "$BUILD_DIR/hermes/scripts/$script_name" + warn " Generated stub: $script_name" +} + +generate_default_config() { + cat > "$BUILD_DIR/hermes/config.yaml" << 'CONF' +# Hermes Portable Rescue — Agent Configuration +# ============================================= +# This config is auto-generated by build/usb-image.sh. +# Customize for production use. + +agent: + name: "Hermes Portable Rescue" + version: "1.0.0" + mode: "rescue" + +llm: + # Strategy determined by research:llm-strat (t_9ba3b456) + # Options: local, api, hybrid + strategy: hybrid + local_model: "/hermes/models/qwen2.5-7b-q4.gguf" + api_fallback: true + api_endpoint: "https://api.openai.com/v1" + +diagnostics: + auto_run: true + timeout_seconds: 3600 + hardware_inventory: true + bsod_analysis: true + driver_check: true + stress_test: false # Manual activation only + backup_restore: false # Manual activation only + +reporting: + log_level: info + output_format: markdown + save_reports: true + report_path: "/hermes/reports/" + +network: + dhcp: true + fallback_static: false + dns: ["8.8.8.8", "1.1.1.1"] +CONF + ok "Default config.yaml generated." +} + +generate_default_autorun() { + cat > "$BUILD_DIR/hermes/autorun.sh" << 'AUTORUN' +#!/bin/sh +# ============================================================================== +# Hermes Portable Rescue — Auto-Launch Script +# ============================================================================== +# Runs when the rescue environment boots. +# Detects environment, mounts necessary filesystems, and launches Hermes agent. + +set -e + +HERMES_DIR="/hermes" +REPORT_DIR="${HERMES_DIR}/reports" +CONFIG="${HERMES_DIR}/config.yaml" + +echo "=====================================================" +echo " Hermes Portable Rescue v1.0.0" +echo " AI-driven Windows PC diagnostic & repair toolkit" +echo "=====================================================" +echo "" + +# Mount the Hermes partition if not already +if [ ! -f "${CONFIG}" ]; then + echo "[*] Locating Hermes runtime..." + for device in /dev/sd* /dev/nvme0n1* /dev/mmcblk*; do + mount_point="/mnt/hermes" + mkdir -p "${mount_point}" 2>/dev/null || true + if mount -o ro "${device}" "${mount_point}" 2>/dev/null; then + if [ -f "${mount_point}/hermes/config.yaml" ]; then + HERMES_DIR="${mount_point}/hermes" + CONFIG="${HERMES_DIR}/config.yaml" + echo "[+] Found Hermes runtime on ${device}" + break + fi + umount "${mount_point}" 2>/dev/null || true + fi + done +fi + +# Create report directory +mkdir -p "${REPORT_DIR}" + +# Run hardware inventory +echo "[*] Running hardware inventory..." +if [ -f "${HERMES_DIR}/scripts/hardware.py" ]; then + python3 "${HERMES_DIR}/scripts/hardware.py" > "${REPORT_DIR}/hardware.txt" 2>&1 || true + echo "[+] Hardware inventory complete" +fi + +# Launch the agent +echo "[*] Starting Hermes agent..." +if [ -f "${HERMES_DIR}/agent/hermes" ]; then + exec "${HERMES_DIR}/agent/hermes" --config "${CONFIG}" +elif command -v hermes &>/dev/null; then + exec hermes --config "${CONFIG}" +else + echo "[!] Hermes agent not found. Starting diagnostic shell..." + echo " Run individual diagnostics:" + echo " python3 ${HERMES_DIR}/scripts/hardware.py" + echo " python3 ${HERMES_DIR}/scripts/bsod.py --dump /path/to/minidump.dmp" + echo " python3 ${HERMES_DIR}/scripts/drivers.py --scan" + echo "" + exec /bin/sh +fi +AUTORUN + chmod +x "$BUILD_DIR/hermes/autorun.sh" + ok "Default autorun.sh generated." +} + +# ─── Build ISO ───────────────────────────────────────────────────────────────── + +build_iso() { + info "Building rescue ISO: ${OUTPUT_ISO}" + + # Create the ISO directory structure + local iso_root="$BUILD_DIR/ISO" + mkdir -p "$iso_root"/{boot/grub,hermes} + + # Copy boot files + if [ -f "$BUILD_DIR/boot/vmlinuz" ] && [ -f "$BUILD_DIR/boot/initrd.img" ]; then + cp "$BUILD_DIR/boot/vmlinuz" "$iso_root/boot/" + cp "$BUILD_DIR/boot/initrd.img" "$iso_root/boot/" + fi + + # Copy GRUB config + if [ -f "$BUILD_DIR/boot/grub/grub.cfg" ]; then + cp "$BUILD_DIR/boot/grub/grub.cfg" "$iso_root/boot/grub/" + fi + + # Copy Hermes runtime + cp -r "$BUILD_DIR/hermes" "$iso_root/" + + # Build the ISO + xorriso -as mkisofs \ + -iso-level 3 \ + -full-iso9660-filenames \ + -volid "${ISO_LABEL}" \ + -eltorito-boot boot/grub/eltorito.img \ + -no-emul-boot \ + -boot-load-size 4 \ + -boot-info-table \ + -eltorito-alt-boot \ + -e boot/grub/efi.img \ + -no-emul-boot \ + -isohybrid-gpt-basdat \ + -o "${OUTPUT_ISO}" \ + "${iso_root}" 2>&1 | tail -5 + + if [ -f "${OUTPUT_ISO}" ]; then + local iso_size + iso_size=$(du -h "${OUTPUT_ISO}" | cut -f1) + ok "ISO built: ${OUTPUT_ISO} (${iso_size})" + else + die "ISO build failed." + fi +} + +# ─── Build Full Disk Image ──────────────────────────────────────────────────── + +build_disk_image() { + info "Building full disk image: ${OUTPUT_IMAGE}" + + # Create a sparse disk image + dd if=/dev/zero of="${OUTPUT_IMAGE}" bs=1M count=0 seek="${IMAGE_SIZE_MB}" status=progress 2>/dev/null + + # Partition: GPT with EFI system partition + data partition + parted -s "${OUTPUT_IMAGE}" mklabel gpt + parted -s "${OUTPUT_IMAGE}" mkpart ESP fat32 1MiB "${EFI_SIZE_MB}MiB" + parted -s "${OUTPUT_IMAGE}" set 1 esp on + parted -s "${OUTPUT_IMAGE}" mkpart primary ext4 "${EFI_SIZE_MB}MiB" 100% + + # Set up loop device + LOOP_DEV=$(losetup --find --show --partscan "${OUTPUT_IMAGE}") + trap 'losetup -d "${LOOP_DEV}" 2>/dev/null || true' EXIT + + # Format partitions + mkfs.vfat -F 32 -n "HERMES-EFI" "${LOOP_DEV}p1" >/dev/null 2>&1 + mkfs.ext4 -L "HERMES-DATA" "${LOOP_DEV}p2" >/dev/null 2>&1 + + # Mount and populate + local mnt_efi=$(mktemp -d) + local mnt_data=$(mktemp -d) + + mount "${LOOP_DEV}p1" "$mnt_efi" + mount "${LOOP_DEV}p2" "$mnt_data" + + # Copy data partition contents + if [ -d "$BUILD_DIR/hermes" ]; then + cp -r "$BUILD_DIR/hermes" "$mnt_data/" + fi + if [ -d "$BUILD_DIR/ventoy" ]; then + cp -r "$BUILD_DIR/ventoy" "$mnt_efi/" + fi + if [ -f "$VENTOY_JSON" ]; then + cp "$VENTOY_JSON" "$mnt_efi/ventoy/ventoy.json" + fi + + # Copy ISOs + mkdir -p "$mnt_data/ISO" + if [ -f "${OUTPUT_ISO}" ]; then + cp "${OUTPUT_ISO}" "$mnt_data/ISO/" + fi + + # Sync and clean up + sync + umount "$mnt_efi" + umount "$mnt_data" + rmdir "$mnt_efi" "$mnt_data" + + losetup -d "${LOOP_DEV}" + trap - EXIT + + local img_size + img_size=$(du -h "${OUTPUT_IMAGE}" | cut -f1) + ok "Disk image built: ${OUTPUT_IMAGE} (${img_size})" +} + +# ─── Write to USB Device ────────────────────────────────────────────────────── + +write_to_device() { + local device="$1" + + if [ ! -b "$device" ]; then + die "Not a block device: $device" + fi + + echo "" + warn "⚠️ ABOUT TO OVERWRITE ENTIRE DEVICE: ${device}" + warn " This will DESTROY ALL DATA on ${device}." + echo "" + + if ! $FORCE; then + echo -n "Type the device path again to confirm: " + read -r confirm + if [ "$confirm" != "$device" ]; then + die "Confirmation mismatch. Aborting." + fi + fi + + info "Writing image to ${device}..." + dd if="${OUTPUT_IMAGE}" of="${device}" bs=4M status=progress conv=fsync + sync + ok "Device ${device} written successfully." +} + +# ─── Verification ───────────────────────────────────────────────────────────── + +verify_image() { + info "Verifying built image..." + + if [ -f "${OUTPUT_ISO}" ]; then + local iso_size + iso_size=$(stat -c%s "${OUTPUT_ISO}" 2>/dev/null || stat -f%z "${OUTPUT_ISO}") + if [ "$iso_size" -gt 1048576 ]; then # > 1 MB + ok " ISO: ${OUTPUT_ISO} ($(du -h "${OUTPUT_ISO}" | cut -f1))" + else + warn " ISO seems too small: $(du -h "${OUTPUT_ISO}" | cut -f1)" + fi + fi + + if [ -f "${OUTPUT_IMAGE}" ]; then + local img_size + img_size=$(stat -c%s "${OUTPUT_IMAGE}" 2>/dev/null || stat -f%z "${OUTPUT_IMAGE}") + if [ "$img_size" -gt 52428800 ]; then # > 50 MB + ok " Image: ${OUTPUT_IMAGE} ($(du -h "${OUTPUT_IMAGE}" | cut -f1))" + else + warn " Image seems too small: $(du -h "${OUTPUT_IMAGE}" | cut -f1)" + fi + fi + + # Verify directory structure + local expected_dirs=( + "$BUILD_DIR/hermes/agent" + "$BUILD_DIR/hermes/scripts" + "$BUILD_DIR/hermes/tools" + ) + for dir in "${expected_dirs[@]}"; do + if [ -d "$dir" ]; then + $VERBOSE && ok " Structure: $dir" + else + warn " Missing directory: $dir" + fi + done + + ok "Verification complete." +} + +# ─── Summary ─────────────────────────────────────────────────────────────────── + +print_summary() { + echo "" + echo "═══════════════════════════════════════════════════════════════" + echo " Hermes Portable Rescue — Build Summary" + echo "═══════════════════════════════════════════════════════════════" + echo "" + echo " Boot environment: ${BOOT_ENV}" + echo " Staging directory: ${BUILD_DIR}" + echo " Staging size: $(du -sh "${BUILD_DIR}" 2>/dev/null | cut -f1 || echo 'N/A')" + echo "" + + if [ -f "${OUTPUT_ISO}" ]; then + echo " ISO: ${OUTPUT_ISO}" + echo " ISO size: $(du -h "${OUTPUT_ISO}" | cut -f1)" + fi + + if [ -f "${OUTPUT_IMAGE}" ]; then + echo " Disk image: ${OUTPUT_IMAGE}" + echo " Image size: $(du -h "${OUTPUT_IMAGE}" | cut -f1)" + fi + + if [ -n "$WRITE_DEVICE" ]; then + echo " Written to device: ${WRITE_DEVICE}" + fi + echo "" + + # Show placeholder/generated stubs + local stub_count + stub_count=$(find "$BUILD_DIR/hermes/scripts" -name "*.py" -exec grep -l "STUB MODULE" {} \; 2>/dev/null | wc -l) + if [ "$stub_count" -gt 0 ]; then + warn " ${stub_count} diagnostic module(s) are stubs (upstream tasks not yet complete)." + echo "" + echo " Upstream tasks still needed:" + echo " build:hermes-runtime (t_c816e37d)" + echo " build:hardware-inventory (t_1215a5f0)" + echo " build:bsod-analyzer (t_10103a48)" + echo " build:driver-updater (t_2afd37ec)" + echo " build:backup-restore (t_0b33ea13)" + echo " build:stress-tests (t_f817392d)" + else + ok " All diagnostic modules present." + fi + echo "═══════════════════════════════════════════════════════════════" + echo "" +} + +# ─── Main ───────────────────────────────────────────────────────────────────── + +main() { + echo "" + echo "╔══════════════════════════════════════════════╗" + echo "║ Hermes Portable Rescue — USB Image Builder ║" + echo "╚══════════════════════════════════════════════╝" + echo "" + + prerequisites + validate_artifacts + setup_directories + copy_hermes_runtime + copy_diagnostics + copy_tools + configure_boot + build_iso + + if ! $ISO_ONLY; then + build_disk_image + fi + + verify_image + print_summary + + if [ -n "$WRITE_DEVICE" ]; then + write_to_device "$WRITE_DEVICE" + fi + + info "Build complete! 🚀" +} + +main "$@" diff --git a/build/ventoy.json b/build/ventoy.json new file mode 100644 index 0000000..6f117c8 --- /dev/null +++ b/build/ventoy.json @@ -0,0 +1,25 @@ +{ + "ventoy": { + "timeout": 30, + "default_image": "rescue.iso", + "menu_style": "LIST", + "theme": { + "file": "/ventoy/theme/theme.txt", + "gfxmode": "1920x1080" + }, + "persistence": [ + { + "image": "/ISO/rescue.iso", + "backend": "/persistence/rescue.dat" + } + ], + "control": [ + { "VTOY_DEFAULT_SEARCH_ROOT": "/ISO" }, + { "VTOY_MENU_TIMEOUT": "0" }, + { "VTOY_TREE_VIEW_MENU_STYLE": "1" }, + { "VTOY_FILT_DOT_UNDERSCORE_FILE": "1" }, + { "VTOY_SORT_CASE_SENSITIVE": "0" }, + { "VTOY_MAX_SEARCH_LEVEL": "3" } + ] + } +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..761621e --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,578 @@ +# Hermes Portable Rescue — Architecture Document + +**Status:** Draft v1.0 +**Author:** Kanban Orchestrator (t_25bcd02c) +**Date:** 2026-07-04 +**Dependencies:** Research T1 (boot-env), T2 (llm-strat), T3 (pc-repair-workflows) + +--- + +## Table of Contents + +1. [Executive Summary](#1-executive-summary) +2. [Chosen Boot Environment](#2-chosen-boot-environment) +3. [USB Layout & Filesystem](#3-usb-layout--filesystem) +4. [LLM Strategy](#4-llm-strategy) +5. [Tool Packaging Strategy](#5-tool-packaging-strategy) +6. [Portable Hermes Runtime Structure](#6-portable-hermes-runtime-structure) +7. [Auto-Launch Flow on Boot](#7-auto-launch-flow-on-boot) +8. [Diagnostic Pipeline Order](#8-diagnostic-pipeline-order) +9. [Build Pipeline](#9-build-pipeline) +10. [Risks & Mitigations](#10-risks--mitigations) + +--- + +## 1. Executive Summary + +Hermes Portable Rescue is an **LLM-driven autonomous diagnostic and repair USB toolkit**. The target user inserts a USB drive into a broken Windows PC, boots from it, and an AI agent auto-launches, diagnoses the system (BSOD analysis, hardware inventory, stress tests, disk health), and guides the repair process. + +**Architecture at a glance:** + +| Component | Decision | Rationale | +|-----------|----------|-----------| +| **Bootloader** | Ventoy | GPL-licensed, MS-signed Secure Boot, multi-ISO capable | +| **Primary OS** | Custom Arch Linux ISO (archiso) | glibc-based, Python-native, full package manager, latest kernel | +| **Secondary OS** | WinPE ISO (optional) | Native chkdsk/sfc/dism for Windows-targeted repairs | +| **Persistence** | NTFS data partition | Survives across sessions, readable from Windows | +| **LLM Runtime** | llama.cpp (CPU) | Offline-capable, no GPU needed | +| **LLM Model (Primary)** | Qwen2.5-7B-Instruct Q4_K_M (~4.3 GB) | Best-in-class structured reasoning at 7B size | +| **LLM Model (Fallback)** | Phi-3-mini-3.8B Q4_K_M (~2.2 GB) | Runs on any hardware (4 GB+ RAM) | +| **API Fallback** | opencode.go / Ollama | Unlocks GPT-4-class reasoning when network is available | +| **USB Size Target** | 32 GB minimum (64 GB ideal) | Fits OS + both models + tools + output space | + +--- + +## 2. Chosen Boot Environment + +### Primary: Ventoy + Custom Arch Linux ISO + +**Ventoy** serves as the bootloader foundation. It installs a tiny (~10 MB) bootloader on the USB, and ISOs are simply copied as files to the FAT32 partition. Ventoy handles UEFI, Legacy BIOS, and Secure Boot via Microsoft-signed shim. + +**Arch Linux (archiso)** was chosen as the live environment for the following reasons: + +- **glibc-based** — full Python 3.11+ compatibility (unlike Alpine's musl) +- **Rolling release** — latest kernel (6.x), best driver support for NVMe, USB3, modern chipsets +- **archiso tooling** — fully customizable ISO builds (pre-install Python, pip, all tools) +- **Package manager (pacman)** — can install additional tools live (TTY-only if needed) +- **~800 MB base** — leaves room for models and tools on a 32 GB USB +- **GPL-licensed** — completely free to redistribute (no WinPE licensing headaches) + +### Secondary: WinPE ISO (Optional Add-on) + +A WinPE-based ISO (such as Hiren's Boot CD PE, ~2.5 GB) can be included as a companion for: + +- Running native Windows tools (chkdsk, sfc, DISM, diskpart) +- BitLocker management (manage-bde) +- Any scenario where Windows-native tooling is strictly required + +### Why NOT WinPE as Primary + +- **Restrictive licensing** for redistribution +- **Python bootstrap is fragile** — must bundle embedded distribution + VC runtime DLLs + pip +- **No package manager** — all dependencies must be pre-bundled +- **Higher engineering cost** for less capability + +### Boot Selection Flow + +``` +Ventoy Boot Menu + ├── 1. Hermes Rescue (Arch Linux) — DEFAULT, auto-boot after 10s + │ └── Auto-launches Hermes agent + ├── 2. Windows PE (Optional) — Manual selection + │ └── Manual tool use + └── 3. Boot from local disk — Bypass rescue +``` + +--- + +## 3. USB Layout & Filesystem + +``` +USB Drive (≥32 GB) +━━━ EFI System Partition (FAT32, ~500 MB) + └── Ventoy bootloader (EFI/BOOT/, EFI/ventoy/) +━━━ ISO/ (FAT32 or exFAT, copy ISOs here) + ├── hermes-rescue-linux.iso ~1 GB (custom Arch Linux) + ├── hirens-boot-cd-pe.iso ~2.5 GB (optional WinPE) + └── memtest86plus.iso ~20 MB (RAM tester, optional) +━━━ DATA/ (NTFS, uses remainder of drive) + ├── hermes/ + │ ├── config.yaml — Hermes agent config + │ ├── models/ + │ │ ├── qwen2.5-7b-q4_k_m.gguf — Primary LLM (~4.3 GB) + │ │ └── phi3-mini-q4_k_m.gguf — Fallback LLM (~2.2 GB) + │ ├── logs/ + │ │ └── session_YYYYMMDD_HHMMSS.log + │ ├── diagnostics/ + │ │ ├── hardware_report_*.json + │ │ ├── bsod_analysis_*.json + │ │ ├── stress_test_*.json + │ │ └── driver_scan_*.json + │ ├── backups/ + │ │ ├── registry/ — Registry hive exports + │ │ ├── system_files/ — File-level backups + │ │ └── disk_images/ — DD/partclone images + │ └── scripts/ + │ ├── custom_recovery.sh — User-customizable repair script + │ └── post_diagnostic_hook.sh — Runs after diagnostics complete + └── hermes_agent.squashfs — Bundled Hermes runtime (see §6) +``` + +### Partition Table + +| Partition | Size | FS | Label | Purpose | +|-----------|------|----|-------|---------| +| /dev/sda1 | ~500 MB | FAT32 | VTOYEFI | Ventoy boot + EFI | +| /dev/sda2 | ~16 GB | FAT32/exFAT | VTOYISO | ISOs (Ventoy reads ISOs from any partition) | +| /dev/sda3 | Remainder | NTFS | DATA | Persistence, config, models, logs | + +**Why NTFS for DATA:** Windows-readable without extra tools, supports large files (models), journaled for crash recovery. + +--- + +## 4. LLM Strategy + +### Decision: Hybrid Local-First + API Fallback + +``` + ┌──────────────┐ + │ User boots │ + │ from USB │ + └──────┬───────┘ + │ + ┌──────▼───────┐ + │ Hermes CLI │ + │ auto-starts │ + └──────┬───────┘ + │ + ┌────────────┴────────────┐ + │ │ + ┌────────▼────────┐ ┌─────────▼─────────┐ + │ Qwen2.5-7B │ │ Network Available?│ + │ Q4_K_M (LOCAL)│ │ │ │ + │ ~5 GB RAM │ │ YES │ NO │ + │ ~15-25 tok/s │ │ │ │ + └────────┬────────┘ └────┬────┘ │ + │ │ │ + │ ┌──────────▼──┐ │ + │ │ API Fallback│ │ + │ │(opencode.go│ │ + │ │ or Ollama) │ │ + │ └──────────┬──┘ │ + │ │ │ + └────────┬───────────┘ │ + │ │ + └──────┬────────────────────┘ + │ + ┌─────────▼────────┐ + │ Fallback: │ + │ Phi-3-mini Q4 │ + │ ~2.9 GB RAM │ + │ (if primary │ + │ won't load) │ + └─────────────────┘ +``` + +### Local Models + +| Role | Model | Quant | File Size | RAM Needed | tok/s | +|------|-------|-------|-----------|------------|-------| +| **Primary** | Qwen2.5-7B-Instruct | Q4_K_M | ~4.3 GB | ~5.0 GB | 15-25 | +| **Fallback** | Phi-3-mini-4k-instruct | Q4_K_M | ~2.2 GB | ~2.9 GB | 25-40 | + +**Model selection rationale:** +- Qwen2.5-7B offers best-in-class **structured reasoning** for BSOD analysis (parsing bugcheck codes, parameter dumps) and **instruction following** for multi-step diagnostic workflows +- Phi-3-mini as fallback covers 4 GB RAM systems where Qwen won't fit +- Both run **CPU-only** via llama.cpp — no GPU required + +### API Fallback + +When the target PC has internet access, the agent can fall back to: +- **opencode.go** (recommended) — for complex multi-step diagnosis +- **Ollama server** query — if the user has Ollama on their home network +- **OpenAI-compatible API** — for any compatible provider + +The API fallback enables GPT-4-class reasoning for edge cases the local model cannot handle (unusual BSOD codes, complex driver chains, recovery planning). + +### USB Storage Budget + +| Component | 32 GB USB | 64 GB USB | +|-----------|-----------|-----------| +| OS ISO | 1.0 GB | 1.0 GB | +| Primary model (Qwen Q4_K_M) | 4.3 GB | 4.3 GB | +| Fallback model (Phi-3 Q4_K_M) | 2.2 GB | 2.2 GB | +| Tools + Runtime | 2.0 GB | 2.0 GB | +| WinPE ISO (optional) | 2.5 GB | 2.5 GB | +| **Total (with WinPE)** | **12.0 GB** | **12.0 GB** | +| **Remaining for output** | **~20 GB** | **~52 GB** | + +Both model files fit comfortably on 32 GB+ USB. + +--- + +## 5. Tool Packaging Strategy + +### Bundled on the Arch Linux ISO + +Pre-installed via pacman into the custom ISO: + +**Core diagnostics:** +- `smartmontools` — SMART health checks +- `dmidecode` — DMI/BIOS information +- `lshw` — full hardware inventory +- `lspci` / `lsusb` — PCI/USB device enumeration +- `ddrescue` — disk rescue imaging +- `testdisk` / `photorec` — partition recovery + file recovery +- `ntfs-3g` — NTFS read/write +- `dislocker` — BitLocker volume access +- `parted` / `gpart` — partition management + +**Stress & burn-in:** +- `stress-ng` — CPU/RAM stress testing (with thermal monitoring) +- `memtest86+` (separate ISO on USB, bootable via Ventoy) — dedicated RAM test + +**Network & connectivity:** +- `curl` / `wget` — HTTP/HTTPS +- `openssh` — SSH client for remote support +- `networkmanager` — WiFi/Ethernet management + +**Python ecosystem:** +- `python` (3.11+) +- `python-pip` +- `python-setuptools` +- `python-virtualenv` + +### Bundled in the DATA partition + +- **Hermes runtime** (`hermes_agent.squashfs`) — squashfs archive of the Hermes Python environment with all dependencies +- **GGUF model files** (`models/`) — Qwen2.5-7B + Phi-3-mini +- **Config** (`config.yaml`) — Hermes agent configuration +- **SquashFS mount script** — mounts runtime at boot + +### Not Bundled (Downloaded on Demand if Network Available) + +- Full OS updates (kernel, packages via pacman) +- Additional models (if user wants larger quantizations) +- Windows driver packages (sourced from vendor websites) + +--- + +## 6. Portable Hermes Runtime Structure + +``` +DATA/hermes_agent.squashfs (compressed, ~200-400 MB) + ├── venv/ + │ ├── bin/ + │ │ ├── python3 — Python 3.11 interpreter + │ │ ├── hermes — Hermes CLI entry point + │ │ └── llama-cli — llama.cpp inference binary (static) + │ └── lib/python3.11/ + │ └── site-packages/ + │ ├── hermes/ — Hermes agent core + │ ├── pyyaml/ — YAML config parsing + │ ├── requests/ — HTTP client + │ └── [deps] — All pip dependencies + ├── src/hermes_portable/ + │ ├── __init__.py + │ ├── diagnostics/ + │ │ ├── hardware.py — Hardware enumeration (T6) + │ │ ├── bsod.py — Minidump parser & LLM analysis (T7) + │ │ ├── drivers.py — Driver scanner (T8) + │ │ ├── backup.py — Backup/restore engine (T9) + │ │ └── stress.py — Stress test orchestration (T10) + │ ├── llm/ + │ │ ├── engine.py — llama.cpp wrapper + │ │ └── prompts.py — Diagnostic prompt templates + │ └── agent/ + │ ├── pipeline.py — Diagnostic pipeline orchestrator + │ └── repair.py — Guided repair flow + ├── config.yaml — Default agent config + └── boot/ + └── autorun.sh — Entry point script (§7) +``` + +**Why SquashFS:** Compressed read-only filesystem keeps the runtime compact. The runtime is mounted read-only, with all mutable state going to `DATA/hermes/`. This prevents corruption from unclean shutdown. + +--- + +## 7. Auto-Launch Flow on Boot + +``` +USB Boot (via Ventoy) + │ + ▼ +Arch Linux ISO boots + │ + ▼ +systemd starts (custom archiso) + │ + ▼ +┌───────────────────────────────┐ +│ 1. Mount DATA partition │ Mounts /dev/sda3 (NTFS) to /mnt/data +│ (ntfs-3g /dev/sda3 /mnt/data) +└───────────────┬───────────────┘ + │ + ▼ +┌───────────────────────────────┐ +│ 2. Mount Hermes runtime │ Mounts squashfs to /mnt/hermes +│ (mount -o loop /mnt/data/ │ +│ hermes_agent.squashfs │ +│ /mnt/hermes) │ +└───────────────┬───────────────┘ + │ + ▼ +┌───────────────────────────────┐ +│ 3. Initialize Python venv │ Symlinks or bind-mounts venv +│ from squashfs │ +└───────────────┬───────────────┘ + │ + ▼ +┌───────────────────────────────┐ +│ 4. Detect LLM model │ Checks /mnt/data/hermes/models/ +│ (fallback logic) │ for Qwen GGUF → tries to load +│ │ → falls back to Phi-3-mini +│ │ → if both fail, pure API mode +└───────────────┬───────────────┘ + │ + ▼ +┌───────────────────────────────┐ +│ 5. Network detection │ Checks internet connectivity +│ (curl --connect-timeout 5 │ If up: enable API fallback +│ https://1.1.1.1) │ If down: local-only mode +└───────────────┬───────────────┘ + │ + ▼ +┌───────────────────────────────┐ +│ 6. Launch Hermes agent │ hermes --config /mnt/data/hermes/ +│ (auto-diagnostic mode) │ config.yaml diagnose +└───────────────┬───────────────┘ + │ + ▼ +┌───────────────────────────────┐ +│ 7. Display diagnostic │ TUI or terminal output +│ progress & results │ Session logged to /mnt/data/hermes/logs/ +└───────────────────────────────┘ +``` + +### Init System Detail + +The custom ISO will use a **oneshot systemd service** (`hermes-autorun.service`) that runs the autorun script after the live environment boots. The ISO is configured for: + +- **Auto-login** to a TTY (no X server needed — saves RAM and boot time) +- **Quiet boot** (no verbose kernel messages) +- **10-second Ventoy timeout** before auto-selecting Hermes Rescue + +The autorun/service does NOT require user interaction to start the diagnostic pipeline — it launches immediately on boot. + +--- + +## 8. Diagnostic Pipeline Order + +The pipeline follows a **risk-tiered approach** — non-destructive, read-only diagnostics first, then progressively more invasive tests. + +### Phase 1: Inventory & Health Check (Non-Destructive) + +``` +Step 1: Hardware Inventory + ├── CPU: model, cores, frequency + ├── RAM: total, slot layout, speed (dmidecode) + ├── GPU: model, VRAM, driver + ├── Disks: model, capacity, interface, media type (lshw + lsblk) + └── Network: adapters, MAC, IP (ip link, lspci) + +Step 2: SMART Health (All Physical Disks) + ├── Read smartctl -a for each disk + ├── Check: reallocated_sectors, pending_sectors, raw_read_error_rate + ├── Check: temperature, power_on_hours, wear_level (SSDs) + └── Flag: PASS / WARNING / FAIL + +Step 3: Boot Configuration Analysis + ├── List Windows installations (if any NTFS volumes found) + ├── Check BCD store (via ntfs-3g mount → bcdedit or registry) + ├── Check for BitLocker protection + └── Report: bootable? encrypted? last successful boot? +``` + +**Actions if Phase 1 completes clean:** Stop here. Report "System appears healthy." + +### Phase 2: Targeted Diagnostics (Data-Driven) + +``` +Step 4: BSOD/Minidump Analysis (if Windows installed) + ├── Locate .dmp files in C:\Windows\Minidump\ + ├── Locate MEMORY.DMP in C:\Windows\ + ├── Parse dump headers for bugcheck code + parameters + ├── LLM: interpret stop code, identify likely driver/cause + └── Suggest: driver rollback, update, hardware test + +Step 5: Disk Integrity Check (if NTFS partitions detected) + ├── Attempt ntfsfix (non-destructive repair) + ├── Check filesystem consistency (ntfs-3g debug flags) + └── Report: clean / fixable / needs chkdsk from Windows + +Step 6: Registry Sanity Check (if Windows installed) + ├── Mount SYSTEM hive (via ntfs-3g) + ├── Check for: corrupted hives, missing critical keys + └── Extract: driver version info, startup programs +``` + +### Phase 3: Interactive Repair (User-Initiated) + +``` +Step 7: Offer Repair Actions (LLM-guided, user confirms each) + ├── Boot repair: mount BCD → rebuildbcd + ├── System file repair: sfc /scannow (requires WinPE) + ├── Image repair: DISM /RestoreHealth (requires WinPE + source) + ├── Registry backup: export current hives + ├── System Restore: enumerate restore points + ├── Driver rollback: identify problematic drivers → guide rollback + └── BitLocker recovery: unlock + suspend (if recovery key available) + +Step 8: Backup (Before Any Destructive Action) + ├── Registry hive export (all hives) + ├── Critical user data backup (Documents, Desktop, AppData) + ├── Full disk image (ddrescue) — optional, space-permitting + └── Verify backup integrity (hash comparison) + +Step 9: Stress Tests (If Hardware Issue Suspected) + ├── CPU stress: stress-ng --cpu 0 --timeout 300s + ├── RAM test: prompt to reboot into MemTest86+ + └── GPU test: basic OpenGL/compute test +``` + +### Phase 4: Recovery & Verification + +``` +Step 10: Apply Repairs (With Rollback) + ├── Each repair step is logged with undo information + ├── Before/after state comparison + └── Verification test after each change + +Step 11: Final Report + ├── What was found + ├── What was fixed + ├── What needs manual attention + ├── USB session logs path + └── Recommendations for long-term health +``` + +--- + +## 9. Build Pipeline + +The build process follows this order: + +``` +1. Build Arch Linux custom ISO (archiso) + └── Pre-install Python 3.11+, pip, all diagnostic tools + └── Configure auto-login + systemd autorun service + └── Set up Hermes autorun service + └── Output: hermes-rescue-linux.iso + +2. Build Hermes runtime squashfs + └── Create Python venv with Hermes + dependencies + └── Bundle diagnostic source modules + └── Include static llama.cpp binary + └── Compress into squashfs + └── Output: hermes_agent.squashfs + +3. Download LLM model GGUF files + └── Qwen2.5-7B-Instruct Q4_K_M from Hugging Face + └── Phi-3-mini-4k-instruct Q4_K_M from Hugging Face + +4. Prepare USB + └── Partition: FAT32 (VTOYEFI + VTOYISO) + NTFS (DATA) + └── Install Ventoy to USB + └── Copy ISOs, models, runtime, config + └── Verify bootability + +5. Test (on real hardware) + └── Cycle through: boot → autorun → diagnostic pipeline → repair → verify + └── Test on: gaming PC, business PC, VM with simulated failures +``` + +### Build Scripts (in `build/`) + +| Script | Purpose | Dependencies | +|--------|---------|-------------| +| `build-iso.sh` | Build custom Arch ISO via archiso | archiso, Docker | +| `build-runtime.sh` | Build Hermes runtime squashfs | Python 3.11, squashfs-tools | +| `download-models.sh` | Download GGUF models from Hugging Face | curl, Hugging Face hub | +| `prepare-usb.sh` | Partition + install Ventoy + copy files | Ventoy, parted, mkfs.* | +| `verify-boot.sh` | QEMU boot test of the ISO | qemu-system-x86_64 | + +--- + +## 10. Risks & Mitigations + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| Secure Boot blocks boot | Medium | High | Ventoy's shim is MS-signed; Arch ISO uses shim-signed | +| Python package incompatibility on Arch | Low | Medium | Pin package versions in build; test with target Python 3.11 | +| Target PC has only 4 GB RAM | Medium | Medium | Phi-3-mini fallback runs in ~2.9 GB; API mode if both models fail | +| USB3 boot fails on old hardware | Low | Medium | Ventoy handles USB2 fallback; test on multiple machines | +| BitLocker prevents disk access | Medium | Medium | dislocker works on Win 10/11 BitLocker; requires recovery key | +| NTFS partition corruption on unclean shutdown | Medium | Low | Data is session-based logs; corruption affects last session only | +| llama.cpp crashes on unsupported CPU | Low | Medium | Detect CPU features (AVX2) before loading model; fallback to API | +| User has no network for API fallback | Medium | Low | Local model handles 95% of diagnostics offline | +| Arch ISO too large for 32 GB USB | Low | Medium | Base is ~800 MB + tools ~200 MB = ~1 GB; models fit comfortably | +| Windows repair needs native Win tools | Medium | Low | Optional WinPE ISO on the same USB; user can reboot into it | + +--- + +## Appendix A: Research Sources + +### T1 — Boot Environment Research +- Microsoft Learn: WinPE technical documentation +- Ventoy project (GitHub, 65k+ stars) +- Arch Linux wiki: archiso +- Alpine Linux documentation +- SystemRescue homepage +- Hiren's Boot CD PE forum +- Medicat USB Discord community + +### T2 — LLM Strategy Research +- Llama.cpp quantization benchmarks (community) +- Qwen2.5-7B technical report (Alibaba) +- Phi-3 technical report (Microsoft) +- OpenRouter model comparison data +- Hugging Face Open LLM Leaderboard + +### T3 — PC Repair Workflows Research +- Microsoft Learn: bootrec, DISM, SFC, WinRE, chkdsk +- Microsoft KB: registry backup/restore +- Sysnative forums, BleepingComputer, TenForums +- ERUNT project documentation +- Hiren's Boot CD PE tool inventory +- Community knowledge: gaming vs business failure patterns + +--- + +## Appendix B: Quick-Reference Command Set + +### Boot Repair Commands (via WinPE) +``` +bootrec /fixmbr — Repair MBR +bootrec /fixboot — Repair boot sector +bootrec /scanos — Scan for Windows installations +bootrec /rebuildbcd — Rebuild BCD store +bcdboot C:\Windows — Copy boot files to EFI partition +``` + +### System File Repair Commands (via WinPE) +``` +sfc /scannow — System File Checker +DISM /Online /Cleanup-Image /RestoreHealth — Component Store Repair +DISM /Online /Cleanup-Image /ScanHealth — Component Store Scan +DISM /Online /Cleanup-Image /CheckHealth — Component Store Check +chkdsk C: /f /r — Check disk + repair + recover bad sectors +``` + +### Registry & Backup Commands +``` +regedit /e backup.reg — Export entire registry +reg save HKLM\SYSTEM system.hiv — Save SYSTEM hive +reg restore HKLM\SOFTWARE software.hiv — Restore SOFTWARE hive +rstrui.exe — System Restore wizard +wbadmin start backup — Command-line backup +``` diff --git a/src/build-hermes-runtime.sh b/src/build-hermes-runtime.sh new file mode 100644 index 0000000..1039edc --- /dev/null +++ b/src/build-hermes-runtime.sh @@ -0,0 +1,339 @@ +#!/usr/bin/env bash +# ============================================================================= +# Hermes Portable Rescue — Build Hermes Runtime +# ============================================================================= +# Builds the portable Hermes Agent runtime for USB deployment. +# Run this script from the project root to produce src/hermes/ content. +# +# Usage: +# ./src/build-hermes-runtime.sh [--output DIR] +# +# The resulting directory can be copied to a USB drive, SD card, or ISO. +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# --- Config ------------------------------------------------------------------ +OUTPUT_DIR="${1:-$PROJECT_ROOT/src/hermes}" +PYTHON_VERSION="${PYTHON_VERSION:-3.11}" +VENV_DIR="$OUTPUT_DIR/venv" +TOOLS_DIR="$OUTPUT_DIR/tools" +SCRIPTS_DIR="$OUTPUT_DIR/scripts" +LIB_DIR="$OUTPUT_DIR/lib" +MODELS_DIR="$OUTPUT_DIR/models" +CONFIG_DIR="$OUTPUT_DIR" + +# Source for static tool binaries (host system or prebuilt cache) +TOOL_CACHE="${TOOL_CACHE:-$PROJECT_ROOT/tools/prebuilt}" + +# Colors +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' +info() { echo -e "${GREEN}[INFO]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; } + +# --- Preflight --------------------------------------------------------------- +info "Building Hermes Portable Rescue Runtime" +info " Output: $OUTPUT_DIR" +info " Python: $PYTHON_VERSION" + +command -v python3 >/dev/null 2>&1 || error "python3 not found" +command -v pip3 >/dev/null 2>&1 || error "pip3 not found" +command -v curl >/dev/null 2>&1 || warn "curl not found — LLM download will fail" + +PYVER="$(python3 --version 2>&1)" +info "Host Python: $PYVER" + +if [[ ! "$PYVER" =~ Python\ 3\.(1[1-9]|2[0-9]) ]]; then + warn "Python $PYVER detected — recommended >=3.11" +fi + +# --- Step 1: Create directory structure -------------------------------------- +info "Creating directory structure…" +mkdir -p "$VENV_DIR" "$TOOLS_DIR" "$SCRIPTS_DIR" "$LIB_DIR" "$MODELS_DIR" + +# --- Step 2: Static config files (copy from source) -------------------------- +info "Copying configuration…" +CONFIG_SRC="$SCRIPT_DIR/hermes" +if [[ -d "$CONFIG_SRC" ]]; then + cp -v "$CONFIG_SRC/config.yaml" "$CONFIG_DIR/config.yaml" 2>/dev/null || warn "config.yaml not found" + cp -v "$CONFIG_SRC/tool-manifest.json" "$CONFIG_DIR/tool-manifest.json" 2>/dev/null || warn "tool-manifest.json not found" + cp -v "$CONFIG_SRC/launch.sh" "$CONFIG_DIR/launch.sh" 2>/dev/null || warn "launch.sh not found" + cp -v "$CONFIG_SRC/launch.ps1" "$CONFIG_DIR/launch.ps1" 2>/dev/null || warn "launch.ps1 not found" + # Copy scripts + if [[ -d "$CONFIG_SRC/scripts" ]]; then + cp -r "$CONFIG_SRC/scripts/"* "$SCRIPTS_DIR/" 2>/dev/null || true + fi +else + warn "Config source $CONFIG_SRC not found — skipping" +fi + +# --- Step 3: Bootstrap Python venv ------------------------------------------- +if [[ ! -f "$VENV_DIR/bin/python3" ]]; then + info "Creating Python $PYTHON_VERSION virtual environment…" + python3 -m venv "$VENV_DIR" --clear +else + info "Virtual environment already exists — skipping create" +fi + +info "Installing Hermes Agent and dependencies…" +"$VENV_DIR/bin/pip3" install --upgrade pip setuptools wheel 2>&1 | tail -1 +"$VENV_DIR/bin/pip3" install --no-cache-dir \ + hermes-agent \ + pyyaml \ + psutil \ + requests \ + 2>&1 | tail -3 + +# Record installed version for provenance +"$VENV_DIR/bin/pip3" freeze > "$OUTPUT_DIR/pip-freeze.txt" + +info "Pruning venv to reduce size…" +# Remove cache, docs, test dirs, __pycache__ +find "$VENV_DIR" -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true +find "$VENV_DIR" -type d -name 'tests' -exec rm -rf {} + 2>/dev/null || true +find "$VENV_DIR" -type f -name '*.pyc' -delete 2>/dev/null || true +rm -rf "$VENV_DIR/share" 2>/dev/null || true + +# --- Step 4: Collect portable diagnostic tools ------------------------------- +if [[ -d "$TOOL_CACHE" ]]; then + info "Copying prebuilt diagnostic tools from $TOOL_CACHE…" + cp -r "$TOOL_CACHE/"* "$TOOLS_DIR/" 2>/dev/null || warn "No tools in cache" +else + info "No tool cache at $TOOL_CACHE — tools must be fetched at runtime" + # Create a stub script that fetches tools on first run + cat > "$TOOLS_DIR/fetch-tools.sh" << 'TOOLEOF' +#!/usr/bin/env bash +# Fetch portable diagnostic tools — run once on target +set -euo pipefail +TOOLS_DIR="$(cd "$(dirname "$0")" && pwd)" +echo "[hermes] Fetching diagnostic tools…" +# smartmontools (static build) +if [[ ! -f "$TOOLS_DIR/smartctl" ]]; then + echo " Downloading smartmontools…" + curl -sL "https://static.smartmontools.org/smartctl-x86_64" -o "$TOOLS_DIR/smartctl" + chmod +x "$TOOLS_DIR/smartctl" +fi +# stress-ng +if [[ ! -f "$TOOLS_DIR/stress-ng" ]]; then + echo " Downloading stress-ng…" + curl -sL "https://github.com/ColinIanKing/stress-ng/raw/master/stress-ng" -o "$TOOLS_DIR/stress-ng" 2>/dev/null && chmod +x "$TOOLS_DIR/stress-ng" || echo " (stress-ng not available as single binary — will use system package)" +fi +echo "[hermes] Tools ready." +TOOLEOF + chmod +x "$TOOLS_DIR/fetch-tools.sh" +fi + +# --- Step 5: Create launch script (if not already copied) -------------------- +if [[ ! -f "$OUTPUT_DIR/launch.sh" ]]; then + cat > "$OUTPUT_DIR/launch.sh" << 'LAUNCHEOF' +#!/usr/bin/env bash +# ============================================================================= +# Hermes Portable Rescue — Launch Script (Linux boot environment) +# ============================================================================= +# Source this or run directly to start the Hermes agent. +# Assumes: Python 3.11+ venv at HERMES_ROOT/venv +# ============================================================================= +set -euo pipefail + +export HERMES_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export HERMES_CONFIG="$HERMES_ROOT/config.yaml" +export PATH="$HERMES_ROOT/venv/bin:$HERMES_ROOT/tools:$PATH" + +echo "╔══════════════════════════════════════════════╗" +echo "║ Hermes Portable Rescue — Runtime v1.0.0 ║" +echo "╚══════════════════════════════════════════════╝" +echo "" +echo "Root: $HERMES_ROOT" +echo "Config: $HERMES_CONFIG" + +# --- Probe network ----------------------------------------------------------- +echo "" +echo "[*] Checking environment…" + +if command -v smartctl &>/dev/null; then + echo " ✓ smartctl (S.M.A.R.T.)" +else + echo " ✗ smartctl not found — run tools/fetch-tools.sh" +fi + +if command -v stress-ng &>/dev/null; then + echo " ✓ stress-ng" +fi + +if command -v dmidecode &>/dev/null; then + echo " ✓ dmidecode" +fi + +echo "" +echo "[*] Starting Hermes Agent…" + +# Launch the agent +hermes --config "$HERMES_CONFIG" +LAUNCHEOF + chmod +x "$OUTPUT_DIR/launch.sh" +fi + +# --- Step 6: Create Windows launch script (if not already copied) ------------ +if [[ ! -f "$OUTPUT_DIR/launch.ps1" ]]; then + cat > "$OUTPUT_DIR/launch.ps1" << 'PS1EOF' +# Hermes Portable Rescue — Launch Script (Windows / WinPE) +# Run: powershell -ExecutionPolicy Bypass -File .\launch.ps1 +param( + [string]$HermesRoot = $PSScriptRoot +) + +$env:HERMES_ROOT = $HermesRoot +$env:HERMES_CONFIG = Join-Path $HermesRoot "config.yaml" +$env:Path = "$HermesRoot\venv\Scripts;$HermesRoot\tools;$env:Path" + +Write-Host "╔══════════════════════════════════════════════╗" -ForegroundColor Cyan +Write-Host "║ Hermes Portable Rescue — Runtime v1.0.0 ║" -ForegroundColor Cyan +Write-Host "╚══════════════════════════════════════════════╝" -ForegroundColor Cyan +Write-Host "" +Write-Host "Root: $HermesRoot" + +# Check Python availability +$python = Get-Command "python.exe" -ErrorAction SilentlyContinue +if (-not $python) { + Write-Host "[ERROR] Python not found in $HermesRoot\venv" -ForegroundColor Red + exit 1 +} +Write-Host "[*] Python: $($python.Path)" + +# Launch agent +Write-Host "[*] Starting Hermes Agent..." +& "$HermesRoot\venv\Scripts\hermes.exe" --config "$env:HERMES_CONFIG" +PS1EOF +fi + +# --- Step 7: Generate tool manifest ------------------------------------------ +if [[ ! -f "$OUTPUT_DIR/tool-manifest.json" ]]; then + cat > "$OUTPUT_DIR/tool-manifest.json" << 'MANIFEST' +{ + "schema_version": "1.0", + "generated_by": "build-hermes-runtime.sh", + "tools": { + "smartctl": { + "description": "S.M.A.R.T. hard drive health check", + "expected_path": "tools/smartctl", + "type": "binary", + "test_command": "smartctl --version", + "source": "https://static.smartmontools.org/" + }, + "stress-ng": { + "description": "CPU and memory stress testing", + "expected_path": "tools/stress-ng", + "type": "binary", + "test_command": "stress-ng --version", + "source": "https://github.com/ColinIanKing/stress-ng" + }, + "dmidecode": { + "description": "Hardware inventory (DMI/SMBIOS)", + "expected_path": "/usr/sbin/dmidecode", + "type": "system_package", + "test_command": "dmidecode --version", + "notes": "Usually pre-installed on Linux live environments" + }, + "lshw": { + "description": "Full hardware listing", + "expected_path": "/usr/bin/lshw", + "type": "system_package", + "test_command": "lshw -version", + "notes": "May need 'lshw' package in live env" + }, + "memtester": { + "description": "Userspace memory tester", + "expected_path": "tools/memtester", + "type": "binary", + "test_command": "memtester --version", + "source": "https://pyropus.ca/software/memtester/" + } + } +} +MANIFEST +fi + +# --- Step 8: Create USB autorun.inf ------------------------------------------ +if [[ ! -f "$PROJECT_ROOT/src/autorun.inf" ]]; then + cat > "$PROJECT_ROOT/src/autorun.inf" << 'AUTORUN' +[Autorun] +Action=Start Hermes Portable Rescue +Label=Hermes Rescue USB +Icon=hermes\hermes.ico +Open=hermes\launch.ps1 +UseAutoPlay=1 +AUTORUN +fi + +# --- Step 9: Create README for the runtime ----------------------------------- +if [[ ! -f "$OUTPUT_DIR/README.md" ]]; then + cat > "$OUTPUT_DIR/README.md" << 'README' +# Hermes Portable Rescue — Runtime Package + +This directory contains the portable Hermes Agent runtime. +It is designed to be copied onto a USB drive and booted on any Windows PC. + +## Contents + +| Path | Purpose | +|------|---------| +| `venv/` | Portable Python 3.11 virtual environment with Hermes Agent | +| `config.yaml` | Hermes Agent configuration for rescue mode | +| `launch.sh` | Linux / Linux-live boot entry point | +| `launch.ps1` | Windows / WinPE entry point | +| `tools/` | Portable diagnostic binaries (smartctl, stress-ng, etc.) | +| `scripts/` | Diagnostic helper scripts | +| `models/` | (optional) Local LLM model files | +| `tool-manifest.json` | Tool registry for Hermes Agent capabilities | +| `pip-freeze.txt` | Installed Python packages manifest | + +## Quick Start + +**Linux:** +```bash +cd /path/to/hermes +./launch.sh +``` + +**Windows (PowerShell):** +```powershell +powershell -ExecutionPolicy Bypass -File .\launch.ps1 +``` + +## Size Budget + +| Component | Approx Size | +|-----------|-------------| +| Python venv (pruned) | 60-80 MB | +| Hermes Agent + deps | 20-30 MB | +| Tools (smartctl, stress-ng) | 10-15 MB | +| Models (optional) | 2-5 GB | +| **Total (minimal)** | **~100 MB** | + +## Notes + +- The venv is created for the **host architecture** (x86_64). +- For ARM64 targets, build on an ARM machine or use QEMU user-mode. +- LLM model files go in `models/` if running local inference. +README +fi + +# --- Summary ---------------------------------------------------------------- +echo "" +echo "============================================" +echo " Hermes Portable Rescue Runtime — Built!" +echo "============================================" +echo "" +echo "Output: $OUTPUT_DIR" +echo "Size: $(du -sh "$OUTPUT_DIR" | cut -f1)" +echo "Python: $("$VENV_DIR/bin/python3" --version 2>/dev/null || echo 'not built')" +echo "" +echo "Next steps:" +echo " 1. Copy $OUTPUT_DIR to your USB drive" +echo " 2. Add diagnostic tools via tools/fetch-tools.sh" +echo " 3. (Optional) Add LLM model to models/" +echo " 4. Boot target machine and run: ./launch.sh" +echo "" diff --git a/src/diagnostics/__init__.py b/src/diagnostics/__init__.py new file mode 100644 index 0000000..d34dff9 --- /dev/null +++ b/src/diagnostics/__init__.py @@ -0,0 +1,28 @@ +""" +Hermes Portable Rescue — Diagnostic modules. + +Modules +------- +hardware : HardwareInventory + CPU, RAM, GPU, disk enumeration and SMART health checks. +stress : RAMStressTester, CPUStressTester, GPUStressTester + Stress-test wrappers (available when stress.py is present). + +Each submodule exposes a ``run() -> dict`` for JSON and a +``text_report() -> str`` for human consumption. +""" +from src.diagnostics.hardware import HardwareInventory + +try: + from src.diagnostics.stress import ( + CPUStressTester, + GPUStressTester, + RAMStressTester, + run_all_stress_tests, + ) +except ImportError: + pass + +__all__ = [ + "HardwareInventory", +] diff --git a/src/diagnostics/__pycache__/__init__.cpython-311.pyc b/src/diagnostics/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c7e387ce677cee235df04ada76d7e7764c50e859 GIT binary patch literal 1008 zcmbVK%}>-o6rbtGev~C>G)lZMi5}R5-QF}YhJ*+aj38n0Krh{)9cZDQHZv^?H@tc_ z;b7u{i}7#ppYUjU)RVU+TsEA1(*nWu=41hjroqtqD6rAK;fI7VzbX54TZanosH#HSu3)_Q`3j?oU{#t0qj zJ7|P`V;?(h-yEQBBY%$(vR0XhnIxNu$mnjw$QF}c&dATxk7PNd`#h0Z=#e<_^N7j5 zWv$nq)vL`4sPIQrF!G$NUb$=hi192DCl&#Fx%qB^Y%Q%XkXOp{LwQIT&toQN7ABlf z?vug#($+Q!7>%-kcmeYcfl6kANeOY`@u2dznar42P$B=`fXKHuD&Bgdm%$q{5;RSj zkYt`7(J-onjsnJstm5|56B0_20v{ya1(hq*3y92jYu$t$r-_6rQ8FBgoX>YjZxNJu z*>Fhq5^O4;OQE9mMIgDl7vwSJ#7nr$<5a2dTfbF{ySTXfPxZP+ zF*BjGn3+&o%uE!gxDWZcG>Y6>nyd6np##ZA*D9K>>nEP;7PC@#egCGxvVYyj>T2;| z^0?DPvOPf>KOGR?PyiD3b zHF-VtRyCvt)rZNmf!V&5nC-eD4>Q+=CSA5()D2k_cOW>7FOr`CN*!ZdY8`BpNWmk7 zpM6J9Z~vvy!tGKs%|?m-AI4<|weFP&L#!M4VYE({RSNC#cwDxSIa})5ly=%wpX@+> PA36`wnK`F_#&h}~o~SZB literal 0 HcmV?d00001 diff --git a/src/diagnostics/__pycache__/hardware.cpython-311.pyc b/src/diagnostics/__pycache__/hardware.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fd8bbbeccbfb654fcd4d058c515b670000d1ba69 GIT binary patch literal 33810 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 dict[str, Any]: + """Recursive dataclass -> dict, filtering None and empty containers.""" + if not is_dataclass(obj): + return obj + out = {} + for f in fields(obj): + val = getattr(obj, f.name) + if isinstance(val, list): + val = [_asdict(v) if is_dataclass(v) else v for v in val] + if not val: + continue + elif is_dataclass(val): + val = _asdict(val) + if not any(val.values()): + continue + elif val is None: + continue + out[f.name] = val + return out + + +@dataclass +class CPUInfo: + """Central processing unit details.""" + + model: str = "" + architecture: str = "" + cores: int = 0 + threads: int = 0 + max_speed_mhz: Optional[float] = None + min_speed_mhz: Optional[float] = None + cache_l2_kb: Optional[int] = None + cache_l3_kb: Optional[int] = None + flags: list[str] = field(default_factory=list) + + +@dataclass +class RAMModule: + """A single physical DIMM / soldered memory module.""" + + size_gb: float = 0 + type: str = "" + speed_mhz: Optional[int] = None + slot: str = "" + manufacturer: str = "" + part_number: str = "" + serial: str = "" + voltage_v: Optional[float] = None + + +@dataclass +class RAMInfo: + """Overall RAM configuration.""" + + total_gb: float = 0 + slots_used: int = 0 + slots_total: int = 0 + modules: list[RAMModule] = field(default_factory=list) + + +@dataclass +class GPUInfo: + """Graphics processing unit details.""" + + model: str = "" + vendor: str = "" + vram_mb: Optional[int] = None + driver: str = "" + pci_slot: str = "" + + +@dataclass +class DiskSMART: + """SMART health summary for one disk.""" + + status: str = "UNKNOWN" + model: str = "" + serial: str = "" + size_gb: float = 0 + interface: str = "" + reallocated_sectors: Optional[int] = None + pending_sectors: Optional[int] = None + temperature_c: Optional[float] = None + power_on_hours: Optional[int] = None + power_cycles: Optional[int] = None + raw_read_error_rate: Optional[int] = None + wear_level: Optional[int] = None + + +@dataclass +class DiskInfo: + """One block device with its SMART data.""" + + device: str = "" + smart: DiskSMART = field(default_factory=DiskSMART) + + +@dataclass +class HardwareReport: + """Complete snapshot of all subsystems.""" + + cpu: CPUInfo = field(default_factory=CPUInfo) + ram: RAMInfo = field(default_factory=RAMInfo) + gpus: list[GPUInfo] = field(default_factory=list) + disks: list[DiskInfo] = field(default_factory=list) + errors: list[str] = field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Shell helpers +# --------------------------------------------------------------------------- + + +def _run(cmd: list[str], timeout: float = 15.0) -> str: + """Run a command, return stdout, or empty string on failure.""" + try: + res = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + ) + return res.stdout + except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.CalledProcessError): + return "" + + +def _parse_int(value: str | None) -> Optional[int]: + if value is None: + return None + try: + return int(value) + except ValueError: + return None + + +def _parse_float(value: str | None) -> Optional[float]: + if value is None: + return None + try: + return float(value) + except ValueError: + return None + + +def _find_sys_block() -> list[Path]: + """List physical block devices (exclude ram, loop, dm, zram).""" + skip_prefixes = ("ram", "loop", "dm-", "zram", "sr", "md") + devices = [] + for entry in Path("/sys/block").iterdir(): + name = entry.name + if any(name.startswith(p) for p in skip_prefixes): + continue + devices.append(entry) + return sorted(devices) + + +# --------------------------------------------------------------------------- +# Collectors +# --------------------------------------------------------------------------- + + +def get_cpu_info(errors: list[str]) -> CPUInfo: + """Collect CPU details from lscpu and /proc/cpuinfo.""" + info = CPUInfo() + + out = _run(["lscpu"]) + if out: + for line in out.splitlines(): + m = re.match(r"Model\s*name[:\s]+(.+)", line, re.IGNORECASE) + if m: + info.model = m.group(1).strip() + m = re.match(r"Architecture[:\s]+(.+)", line, re.IGNORECASE) + if m: + info.architecture = m.group(1).strip() + m = re.match(r"^CPU\(s\)[:\s]+(\d+)", line, re.IGNORECASE) + if m: + info.threads = int(m.group(1)) + m = re.match(r"Core\(s\)\s*per\s*socket[:\s]+(\d+)", line, re.IGNORECASE) + if m: + info.cores = int(m.group(1)) + m = re.match(r"CPU\s*max\s*MHz[:\s]+([\d.]+)", line, re.IGNORECASE) + if m: + info.max_speed_mhz = round(float(m.group(1))) + m = re.match(r"CPU\s*min\s*MHz[:\s]+([\d.]+)", line, re.IGNORECASE) + if m: + info.min_speed_mhz = round(float(m.group(1))) + m = re.match(r"L2\s*cache[:\s]+(\d+)", line, re.IGNORECASE) + if m: + info.cache_l2_kb = int(m.group(1)) + m = re.match(r"L3\s*cache[:\s]+(\d+)", line, re.IGNORECASE) + if m: + info.cache_l3_kb = int(m.group(1)) + m = re.match(r"Flags[:\s]+(.+)", line, re.IGNORECASE) + if m: + info.flags = m.group(1).strip().split() + + if not info.model: + try: + cpuinfo = Path("/proc/cpuinfo").read_text() + for line in cpuinfo.splitlines(): + m = re.match(r"model\s*name\s*:\s*(.+)", line, re.IGNORECASE) + if m and not info.model: + info.model = m.group(1).strip() + cores = set() + threads = 0 + for line in cpuinfo.splitlines(): + m = re.match(r"core\s*id\s*:\s*(\d+)", line) + if m: + cores.add(m.group(1)) + m2 = re.match(r"processor\s*:\s*(\d+)", line) + if m2: + threads = max(threads, int(m2.group(1)) + 1) + if cores and not info.cores: + info.cores = len(cores) + if threads and not info.threads: + info.threads = threads + except (OSError, ValueError): + pass + + return info + + +def get_ram_info(errors: list[str]) -> RAMInfo: + """Collect RAM configuration from dmidecode.""" + info = RAMInfo() + + out = _run(["dmidecode", "--type", "17"], timeout=10.0) + if not out: + errors.append("dmidecode not available or returned no memory info") + return info + + blocks = re.split(r"\n\s*\n", out) + current: dict[str, str] = {} + + for block in blocks: + if "Memory Device" not in block: + continue + current.clear() + for line in block.splitlines(): + m = re.match(r"\s*(\w[\w\s]*?):\s+(.*)", line) + if m: + key = m.group(1).strip() + val = m.group(2).strip() + current[key] = val + + size_str = current.get("Size", "No Module Installed") + if not size_str or "No Module Installed" in size_str or "Not Installed" in size_str: + continue + + sm = re.match(r"(\d+)\s*(MB|GB)", size_str) + if not sm: + continue + num = int(sm.group(1)) + unit = sm.group(2) + size_mb = float(num) * 1024 if unit == "GB" else float(num) + + module = RAMModule( + size_gb=round(size_mb / 1024, 1), + type=current.get("Type", ""), + speed_mhz=_parse_int(current.get("Speed")), + slot=current.get("Locator", ""), + manufacturer=current.get("Manufacturer", ""), + part_number=current.get("Part Number", ""), + serial=current.get("Serial Number", ""), + voltage_v=_parse_float(current.get("Configured Voltage", current.get("Voltage", ""))), + ) + info.modules.append(module) + info.total_gb += module.size_gb + + info.slots_used = len(info.modules) + + slots_out = _run(["dmidecode", "--type", "16"], timeout=5.0) + if slots_out: + for line in slots_out.splitlines(): + m = re.match(r"\s*Number\s+Of\s+Devices\s*:\s*(\d+)", line) + if m: + info.slots_total = int(m.group(1)) + else: + info.slots_total = info.slots_used + + info.total_gb = round(info.total_gb, 1) + return info + + +def get_gpu_info(errors: list[str]) -> list[GPUInfo]: + """Collect GPU information from lspci and lshw.""" + gpus: list[GPUInfo] = [] + + out = _run(["lspci", "-mm"]) + if not out: + errors.append("lspci not available — cannot enumerate PCI devices") + return gpus + + gpu_lines = [line for line in out.splitlines() if "VGA" in line or "3D" in line or "Display" in line] + + for line in gpu_lines: + parts = line.strip().split('"') + if len(parts) < 3: + continue + raw_desc = parts[1] if len(parts) > 1 else "" + vendor = parts[3] if len(parts) > 3 else "" + model_raw = parts[5] if len(parts) > 5 else "" + pci_slot = parts[0].strip().split("pci@")[-1].strip() if "pci@" in parts[0] else parts[0].strip() + + gpu = GPUInfo(model=model_raw.strip(), vendor=vendor.strip(), pci_slot=pci_slot) + gpus.append(gpu) + + lshw_out = _run(["lshw", "-c", "display"], timeout=10.0) + for gpu in gpus: + if lshw_out: + blocks = re.split(r"\*\-\w+", lshw_out) + for block in blocks: + if gpu.pci_slot and gpu.pci_slot in block: + m = re.search(r"size:\s*(\d+)([KMGT]i?B?)", block, re.IGNORECASE) + if m: + size_val = int(m.group(1)) + unit = m.group(2).upper() + unit_map = {"KB": 1, "KIB": 1, "MB": 1024, "MIB": 1024, "GB": 1024**2, "GIB": 1024**2, "TB": 1024**3, "TIB": 1024**3} + vram_kb = size_val * unit_map.get(unit, 1) + if vram_kb >= 1024: + gpu.vram_mb = vram_kb // 1024 + break + + if gpu.pci_slot: + driver_path = Path(f"/sys/bus/pci/devices/{gpu.pci_slot}/driver") + if driver_path.exists(): + try: + gpu.driver = driver_path.resolve().name + except OSError: + pass + + return gpus + + +def get_disk_info(errors: list[str]) -> list[DiskInfo]: + """Collect disk inventory and SMART health for all physical drives.""" + disks: list[DiskInfo] = [] + + devices = _find_sys_block() + if not devices: + errors.append("no physical block devices found in /sys/block") + return disks + + for dev in devices: + device_name = dev.name + dev_path = f"/dev/{device_name}" + + device_dir = dev / "device" + if not device_dir.exists(): + continue + + disk = DiskInfo(device=dev_path) + + size_bytes = _parse_int((dev / "size").read_text().strip()) if (dev / "size").exists() else None + if size_bytes: + disk.smart.size_gb = round(size_bytes * 512 / (1024**3), 1) + + model_file = dev / "device" / "model" + if model_file.exists(): + disk.smart.model = model_file.read_text().strip() + + serial_file = dev / "device" / "serial" + if serial_file.exists(): + disk.smart.serial = serial_file.read_text().strip() + + removable = (dev / "removable").read_text().strip() if (dev / "removable").exists() else "0" + rotational = (dev / "queue" / "rotational").read_text().strip() if (dev / "queue" / "rotational").exists() else "1" + if rotational == "0": + disk.smart.interface = "NVMe" if "nvme" in device_name else "SSD" + else: + disk.smart.interface = "HDD" + if removable == "1": + disk.smart.interface += "/REMOVABLE" + + smart_out = _run(["smartctl", "-a", dev_path], timeout=15.0) + if not smart_out: + errors.append(f"smartctl failed or unavailable for {dev_path}") + else: + if "PASSED" in smart_out: + disk.smart.status = "PASSED" + elif "FAILED" in smart_out: + disk.smart.status = "FAILED" + + m = re.search(r"Temperature_Celsius\s+\d+\s+(\d+)", smart_out) + if m: + disk.smart.temperature_c = float(m.group(1)) + else: + m = re.search(r"Temperature:\s+(\d+)\s+Celsius", smart_out) + if m: + disk.smart.temperature_c = float(m.group(1)) + + m = re.search(r"Reallocated_Sector_Ct\s+0x[\da-f]+\s+\d+\s+\d+\s+(\d+)", smart_out) + if m: + disk.smart.reallocated_sectors = int(m.group(1)) + + m = re.search(r"Current_Pending_Sector\s+0x[\da-f]+\s+\d+\s+\d+\s+(\d+)", smart_out) + if m: + disk.smart.pending_sectors = int(m.group(1)) + + m = re.search(r"Power_On_Hours\s+0x[\da-f]+\s+\d+\s+\d+\s+(\d+)", smart_out) + if m: + disk.smart.power_on_hours = int(m.group(1)) + else: + m = re.search(r"Power\s*On\s*Hours\s*:\s*(\d+)", smart_out) + if m: + disk.smart.power_on_hours = int(m.group(1)) + + m = re.search(r"Power_Cycle_Count\s+0x[\da-f]+\s+\d+\s+\d+\s+(\d+)", smart_out) + if m: + disk.smart.power_cycles = int(m.group(1)) + + m = re.search(r"Raw_Read_Error_Rate\s+0x[\da-f]+\s+\d+\s+\d+\s+(\d+)", smart_out) + if m: + disk.smart.raw_read_error_rate = int(m.group(1)) + + m = re.search(r"Percentage\s*Used\s*:\s*(\d+)", smart_out) + if m: + disk.smart.wear_level = 100 - int(m.group(1)) + + disks.append(disk) + + return disks + + +# --------------------------------------------------------------------------- +# Main inventory class +# --------------------------------------------------------------------------- + + +class HardwareInventory: + """Probe and report on all hardware subsystems of the local machine. + + Call ``run()`` once to collect everything; subsequent calls return the + cached report. Use ``text_report()`` for a human-readable summary or + ``to_json()`` for the full JSON output. + + Parameters + ---------- + log_warnings : bool + If True, emit a Python log warning on each non-fatal probing + issue (missing tools, unsupported hardware, etc.). + """ + + def __init__(self, log_warnings: bool = False) -> None: + self._report: Optional[HardwareReport] = None + self._log_warnings = log_warnings + + def run(self) -> dict[str, Any]: + """Run all probes and return a JSON-serialisable dict.""" + report = self._probe() + self._report = report + return _asdict(report) + + def text_report(self) -> str: + """Return a human-readable summary of the last ``run()`` result. + + Raises RuntimeError if ``run()`` has not been called yet. + """ + if self._report is None: + return self._fallback_summary() + r = self._report + lines: list[str] = [] + + lines.append("=" * 60) + lines.append(" HARDWARE INVENTORY REPORT") + lines.append("=" * 60) + + lines.append("") + lines.append("-- CPU --") + lines.append(f" Model: {r.cpu.model or 'N/A'}") + lines.append(f" Arch: {r.cpu.architecture or 'N/A'}") + lines.append(f" Cores: {r.cpu.cores or '?'}") + lines.append(f" Threads: {r.cpu.threads or '?'}") + if r.cpu.max_speed_mhz: + lines.append(f" Max Speed: {r.cpu.max_speed_mhz:.0f} MHz") + if r.cpu.cache_l3_kb: + lines.append(f" L3 Cache: {r.cpu.cache_l3_kb / 1024:.0f} MB") + + lines.append("") + lines.append("-- RAM --") + lines.append(f" Total: {r.ram.total_gb:.1f} GB ({r.ram.slots_used}/{r.ram.slots_total} slots used)") + for mod in r.ram.modules: + speed = f" @ {mod.speed_mhz} MHz" if mod.speed_mhz else "" + lines.append(f" + {mod.slot or '?'}: {mod.size_gb} GB {mod.type}{speed}") + + if r.gpus: + lines.append("") + lines.append("-- GPU --") + for gpu in r.gpus: + vram = f" ({gpu.vram_mb} MB)" if gpu.vram_mb else "" + drv = f" | driver: {gpu.driver}" if gpu.driver else "" + lines.append(f" {gpu.model or 'Unknown GPU'}{vram}{drv}") + + if r.disks: + lines.append("") + lines.append("-- DISKS --") + for disk in r.disks: + s = disk.smart + status_str = s.status if s.status == "PASSED" else f"!! {s.status}" + temp = f", {s.temperature_c} C" if s.temperature_c else "" + poh = f", {s.power_on_hours}h" if s.power_on_hours else "" + realloc = "" + if s.reallocated_sectors is not None and s.reallocated_sectors > 0: + realloc = f", !! {s.reallocated_sectors} reallocated" + lines.append(f" {disk.device} ({s.size_gb:.0f} GB) {s.model or '?'}") + lines.append(f" SMART: {status_str}{temp}{poh}{realloc}") + + if r.errors: + lines.append("") + lines.append("-- WARNINGS --") + for err in r.errors: + lines.append(f" !! {err}") + + lines.append("") + lines.append("=" * 60) + + return "\n".join(lines) + + def to_json(self, indent: int = 2) -> str: + """Return the full inventory as a JSON string.""" + return json.dumps(self.run(), indent=indent, default=str) + + def _probe(self) -> HardwareReport: + report = HardwareReport() + err = report.errors + + report.cpu = get_cpu_info(err) + report.ram = get_ram_info(err) + report.gpus = get_gpu_info(err) + report.disks = get_disk_info(err) + + if self._log_warnings and err: + for e in err: + logger.warning("hardware probe: %s", e) + + return report + + def _fallback_summary(self) -> str: + lines = [ + "HardwareInventory -- no probe results yet.", + "Call .run() first to collect data.", + "", + ] + try: + lines.append(f"Python: {sys.version.split()[0]}") + lines.append(f"Platform: {sys.platform}") + uname = os.uname() + lines.append(f"Kernel: {uname.sysname} {uname.release} {uname.machine}") + except Exception: + pass + return "\n".join(lines) diff --git a/src/diagnostics/stress.py b/src/diagnostics/stress.py new file mode 100644 index 0000000..9e89169 --- /dev/null +++ b/src/diagnostics/stress.py @@ -0,0 +1,995 @@ +""" +stress.py — RAM, CPU, and GPU stress testing orchestration. + +Designed for the Hermes Portable Rescue USB environment. +Each tester class follows the same interface: + - run() -> dict: structured results for LLM consumption + - report() -> str: human-readable summary + +Usage +----- + from src.diagnostics.stress import run_all_stress_tests + results = run_all_stress_tests(duration=60) + print(results) + +Or from CLI: + python -m src.diagnostics.stress --duration 60 --ram --cpu --gpu + +Requires bundled tools: + - stress-ng (CPU + RAM stress) + - memtester (RAM stress fallback) + - nvidia-smi, glxinfo, glmark2 (GPU) +""" + +import logging +import os +import re +import shutil +import subprocess +import sys +import time +from dataclasses import dataclass, field, asdict +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +logger = logging.getLogger("stress") + + +# --------------------------------------------------------------------------- +# Data structures +# --------------------------------------------------------------------------- + + +@dataclass +class StressResult: + """Structured result of a single stress test run.""" + + component: str # "ram", "cpu", "gpu" + backend: str # tool used ("stress-ng", "memtester", "memtest86+", etc.) + status: str # "passed", "failed", "error", "skipped" + duration_seconds: float + summary: str # one-line human verdict + details: Dict[str, Any] = field(default_factory=dict) + errors: List[str] = field(default_factory=list) + raw_output: str = "" + + def to_dict(self) -> dict: + return asdict(self) + + @staticmethod + def skipped(component: str, reason: str) -> "StressResult": + return StressResult( + component=component, + backend="none", + status="skipped", + duration_seconds=0.0, + summary=f"Skipped: {reason}", + ) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _find_tool(name: str) -> Optional[str]: + """Return absolute path to *name* if it exists on PATH, else None.""" + return shutil.which(name) + + +def _run( + cmd: List[str], + timeout: int = 300, + env: Optional[Dict[str, str]] = None, +) -> Tuple[int, str, str]: + """Run *cmd* and return (returncode, stdout, stderr).""" + base_env = os.environ.copy() + if env: + base_env.update(env) + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + env=base_env, + ) + return proc.returncode, proc.stdout, proc.stderr + except FileNotFoundError: + return -1, "", f"Command not found: {cmd[0]}" + except subprocess.TimeoutExpired: + return -2, "", f"Command timed out after {timeout}s" + except Exception as exc: + return -3, "", str(exc) + + +# --------------------------------------------------------------------------- +# RAM Stress Tester +# --------------------------------------------------------------------------- + + +class RAMStressTester: + """ + RAM stress testing with multiple backends. + + Backends (tried in order): + 1. stress-ng --vm --vm-bytes (user-space RAM stress) + 2. memtester (user-space RAM test) + 3. memtest86+ result parser (reads existing report) + """ + + BACKEND_PRIORITY = ["stress-ng", "memtester", "memtest86+result"] + + def __init__( + self, + duration: int = 60, + workers: Optional[int] = None, + vm_bytes: str = "80%", + memtest_iterations: int = 2, + memtest86_report_path: Optional[str] = None, + ): + self.duration = duration + self.workers = workers or self._default_workers() + self.vm_bytes = vm_bytes + self.memtester_iterations = memtest_iterations + self.memtest86_report = memtest86_report_path + + # ------------------------------------------------------------------ + # Public interface + # ------------------------------------------------------------------ + + def run(self) -> StressResult: + backend, fn = self._pick_backend() + if fn is None: + return StressResult.skipped("ram", f"no backend available ({backend})") + logger.info("RAM testing with backend=%s workers=%d duration=%ds", backend, self.workers, self.duration) + t0 = time.monotonic() + result = fn() + result.duration_seconds = round(time.monotonic() - t0, 1) + result.backend = backend + return result + + def report(self, result: StressResult) -> str: + lines = [ + f"--- RAM Stress Test ---", + f" Backend: {result.backend}", + f" Status: {result.status}", + f" Duration: {result.duration_seconds}s", + f" Summary: {result.summary}", + ] + if result.details: + for k, v in result.details.items(): + lines.append(f" {k}: {v}") + if result.errors: + lines.append(" Errors:") + for e in result.errors: + lines.append(f" - {e}") + return "\n".join(lines) + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + @staticmethod + def _default_workers() -> int: + """Heuristic: use half of available CPUs for VM stress workers.""" + try: + return max(1, os.cpu_count() or 2 // 2) + except Exception: + return 1 + + def _pick_backend(self) -> Tuple[str, Optional[callable]]: + """Return (label, callable) for the best available backend.""" + if _find_tool("stress-ng"): + return "stress-ng", self._run_stress_ng + if _find_tool("memtester"): + return "memtester", self._run_memtester + if self.memtest86_report or _find_tool("memtest86+report"): + return "memtest86+result", self._parse_memtest86 + return "none", None + + def _run_stress_ng(self) -> StressResult: + """Run stress-ng memory stress (--vm --vm-bytes ).""" + cmd = [ + "stress-ng", + "--vm", str(self.workers), + "--vm-bytes", self.vm_bytes, + "--timeout", f"{self.duration}s", + "--metrics-brief", + "--no-rand-seed", + ] + rc, stdout, stderr = _run(cmd, timeout=self.duration + 30) + + errors = [] + if stderr: + errors.append(stderr.strip()) + + details = self._parse_stress_ng_metrics(stdout) + details["command"] = " ".join(cmd) + details["workers"] = self.workers + + if rc == -1: + return StressResult( + component="ram", + status="error", + summary="stress-ng not found", + details=details, + errors=errors, + raw_output=stdout + "\n" + stderr, + ) + if rc == -2: + return StressResult( + component="ram", + status="error", + summary="stress-ng timed out", + details=details, + errors=errors, + raw_output=stdout + "\n" + stderr, + ) + + # stress-ng exits 0 on clean completion even if errors occurred + status = "passed" if rc == 0 else "error" + bogo_ops = details.get("total_bogo_ops", 0) + summary = ( + f"Completed {bogo_ops} bogo-ops across {self.workers} VM workers" + if bogo_ops > 0 else + f"Finished with status={status}" + ) + return StressResult( + component="ram", + status=status, + summary=summary, + details=details, + errors=errors, + raw_output=stdout + "\n" + stderr, + ) + + @staticmethod + def _parse_stress_ng_metrics(text: str) -> Dict[str, Any]: + """Extract bogo-ops and other metrics from stress-ng --metrics-brief.""" + metrics: Dict[str, Any] = {} + total_bogo = 0.0 + bogo_per_sec = 0.0 + # stress-ng metrics lines look like: + # stress-ng: info: [123] stress-ng-vm: 4 runouts, 0 stalled... + # stress-ng: info: [123] stress-ng-metrics: vm 1234.56 bogo-ops ... + for line in text.splitlines(): + if "bogo-ops" in line.lower(): + m = re.search(r"vm\s+([\d.]+)\s+bogo-ops", line, re.IGNORECASE) + if m: + total_bogo = float(m.group(1)) + m2 = re.search(r"bogo-ops/s\s+real\s+([\d.]+)", line, re.IGNORECASE) + if m2: + bogo_per_sec = float(m2.group(1)) + metrics["total_bogo_ops"] = round(total_bogo, 1) + metrics["bogo_ops_per_sec"] = round(bogo_per_sec, 3) if bogo_per_sec else 0.0 + return metrics + + def _run_memtester(self) -> StressResult: + """Run memtester .""" + # Convert vm_bytes like "80%" to a usable size for memtester + # memtester takes size in megabytes directly + size_mb = self._vm_bytes_to_mb() + if size_mb <= 0: + size_mb = 256 # fallback + cmd = ["memtester", f"{size_mb}M", str(self.memtester_iterations)] + rc, stdout, stderr = _run(cmd, timeout=max(self.duration, 120)) + + errors = [] + if stderr: + errors.append(stderr.strip()) + + details = self._parse_memtester_output(stdout) + details["command"] = " ".join(cmd) + details["size_mb"] = size_mb + details["iterations"] = self.memtester_iterations + + status = "passed" if rc == 0 else "failed" + if rc < 0: + status = "error" + + failure_count = details.get("failures", 0) + summary = f"memtester: {details.get('loops_completed', 0)} loops, {failure_count} failures" if status != "error" else "memtester run failed" + return StressResult( + component="ram", + status=status, + summary=summary, + details=details, + errors=errors, + raw_output=stdout + "\n" + stderr, + ) + + @staticmethod + def _parse_memtester_output(text: str) -> Dict[str, Any]: + """Parse memtester output for pass/fail information.""" + details: Dict[str, Any] = {} + loops = 0 + failures = 0 + for line in text.splitlines(): + m = re.match(r"Loop\s+(\d+)", line, re.IGNORECASE) + if m: + loops = max(loops, int(m.group(1))) + if "FAILURE" in line.upper(): + failures += 1 + details["loops_completed"] = loops + details["failures"] = failures + return details + + def _parse_memtest86(self) -> StressResult: + """Parse existing MemTest86+ report (result XML or text file).""" + report_path = self.memtest86_report + if report_path is None: + # Common locations + for candidate in [ + "/MemTest86-Report.xml", + "/boot/MemTest86-Report.xml", + "/memtest86/report.xml", + "~/MemTest86-Report.xml", + ]: + expanded = os.path.expanduser(candidate) + if os.path.isfile(expanded): + report_path = expanded + break + + if not report_path or not os.path.isfile(report_path): + return StressResult( + component="ram", + status="skipped", + summary="MemTest86+ report not found; run MemTest86+ on next boot", + details={"suggested_path": "/MemTest86-Report.xml"}, + ) + + try: + text = Path(report_path).read_text() + except Exception as exc: + return StressResult( + component="ram", + status="error", + summary=f"Cannot read report: {exc}", + ) + + details = self._parse_memtest86_xml(text) + details["report_path"] = report_path + summary = ( + f"MemTest86+ report: {details.get('passes', '?')} passes, " + f"{details.get('errors', 0)} errors" + ) + status = "passed" if details.get("errors", 0) == 0 else "failed" + return StressResult( + component="ram", + status=status, + summary=summary, + details=details, + raw_output=text, + ) + + @staticmethod + def _parse_memtest86_xml(text: str) -> Dict[str, Any]: + """Naïve extraction of key fields from MemTest86+ XML report.""" + def _tag(tag: str) -> str: + m = re.search(rf"<{tag}[^>]*>(.*?)", text, re.DOTALL) + return m.group(1).strip() if m else "" + + passes_str = _tag("Passes") + return { + "passes": int(passes_str) if passes_str.isdigit() else 0, + "errors": _tag("Errors"), + "test_start_time": _tag("TestStartTime"), + "test_end_time": _tag("TestEndTime"), + "memory_size": _tag("MemorySize"), + "cpu_type": _tag("CpuType"), + "cpu_speed": _tag("CpuSpeed"), + } + + def _vm_bytes_to_mb(self) -> int: + """Convert a vm_bytes string (e.g. '80%' or '512M') to megabytes.""" + s = str(self.vm_bytes).strip().lower() + if s.endswith("%"): + try: + pct = int(s.rstrip("%")) + # Estimate available RAM via /proc/meminfo + mem_total_kb = 0 + try: + for line in Path("/proc/meminfo").read_text().splitlines(): + if line.startswith("MemTotal:"): + parts = line.split() + if len(parts) >= 2: + mem_total_kb = int(parts[1]) + break + except Exception: + mem_total_kb = 8 * 1024 * 1024 # fallback: 8 GB + mem_total_mb = mem_total_kb // 1024 + return max(64, mem_total_mb * pct // 100) + except (ValueError, TypeError): + return 512 + if s.endswith("g"): + try: + return int(s.rstrip("g")) * 1024 + except ValueError: + return 512 + if s.endswith("m"): + try: + return int(s.rstrip("m")) + except ValueError: + return 512 + try: + return int(s) + except (ValueError, TypeError): + return 512 + + +# --------------------------------------------------------------------------- +# CPU Stress Tester +# --------------------------------------------------------------------------- + + +class CPUStressTester: + """ + CPU stress testing via stress-ng. + + Tests several CPU subsystems in sequence or parallel: + - cpu (integer arithmetic) + - matrix (floating-point matrix operations) + - fpu (FPU stress) + - cache (L1/L2 cache thrashing) + - context (context switching) + + Each stressor is run briefly to exercise different CPU aspects. + """ + + DEFAULT_STRESSORS = ["cpu", "matrix", "fpu", "cache"] + + def __init__( + self, + duration: int = 60, + workers: Optional[int] = None, + stressors: Optional[List[str]] = None, + sequential: bool = True, + ): + self.duration = duration + self.workers = workers or self._default_workers() + self.stressors = stressors or list(self.DEFAULT_STRESSORS) + self.sequential = sequential + + # ------------------------------------------------------------------ + # Public interface + # ------------------------------------------------------------------ + + def run(self) -> StressResult: + backend = self._pick_backend() + if backend is None: + return StressResult.skipped("cpu", "stress-ng not found on PATH") + + t0 = time.monotonic() + if self.sequential: + result = self._run_sequential(backend) + else: + result = self._run_parallel(backend) + result.duration_seconds = round(time.monotonic() - t0, 1) + result.backend = backend + return result + + def report(self, result: StressResult) -> str: + lines = [ + f"--- CPU Stress Test ---", + f" Backend: {result.backend}", + f" Status: {result.status}", + f" Duration: {result.duration_seconds}s", + f" Summary: {result.summary}", + ] + details = result.details + if "stressors_run" in details: + lines.append(f" Stressors run: {', '.join(details['stressors_run'])}") + if "stressor_results" in details: + for sres in details["stressor_results"]: + lines.append(f" - {sres.get('name', '?')}: {sres.get('bogo_ops', 0)} bogo-ops") + if result.errors: + lines.append(" Errors:") + for e in result.errors: + lines.append(f" - {e}") + return "\n".join(lines) + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + @staticmethod + def _default_workers() -> int: + try: + return os.cpu_count() or 2 + except Exception: + return 2 + + @staticmethod + def _pick_backend() -> Optional[str]: + path = _find_tool("stress-ng") + return path + + def _run_sequential(self, backend: str) -> StressResult: + """Run each stressor one at a time so we get per-stressor metrics.""" + per_stressor_duration = max(10, self.duration // len(self.stressors)) + all_stressor_results: List[Dict[str, Any]] = [] + all_errors: List[str] = [] + total_bogo = 0.0 + + for stressor in self.stressors: + cmd = [ + backend, + "--" + stressor, str(self.workers), + "--timeout", f"{per_stressor_duration}s", + "--metrics-brief", + "--no-rand-seed", + ] + logger.info("CPU stressor: %s (workers=%d, duration=%ds)", stressor, self.workers, per_stressor_duration) + rc, stdout, stderr = _run(cmd, timeout=per_stressor_duration + 30) + + if rc < 0: + all_errors.append(f"{stressor}: {stderr.strip() or 'unknown error'}") + all_stressor_results.append({"name": stressor, "status": "error", "bogo_ops": 0}) + continue + + metrics = self._parse_single_stressor_metrics(stdout, stressor) + metrics["name"] = stressor + metrics["status"] = "passed" if rc == 0 else "error" + all_stressor_results.append(metrics) + total_bogo += metrics.get("bogo_ops", 0.0) + all_errors.extend(self._extract_errors(stdout + "\n" + stderr)) + + details: Dict[str, Any] = {} + details["stressors_run"] = self.stressors + details["stressor_results"] = all_stressor_results + details["total_bogo_ops"] = round(total_bogo, 1) + + status = "passed" if not all_errors else "failed" + summary = ( + f"CPU stress: {len(self.stressors)} stressors, " + f"{details['total_bogo_ops']} total bogo-ops" + ) + if all_errors: + summary += f", {len(all_errors)} errors" + + return StressResult( + component="cpu", + status=status, + summary=summary, + details=details, + errors=all_errors, + ) + + def _run_parallel(self, backend: str) -> StressResult: + """Run all stressors in one stress-ng invocation (--all).""" + # Build command: stress-ng --cpu N --matrix N --fpu N --cache N --timeout Xs --metrics-brief + cmd = [backend] + for stressor in self.stressors: + cmd.extend(["--" + stressor, str(self.workers)]) + cmd.extend(["--timeout", f"{self.duration}s", "--metrics-brief", "--no-rand-seed"]) + + logger.info("CPU parallel stress: stressors=%s workers=%d", self.stressors, self.workers) + rc, stdout, stderr = _run(cmd, timeout=self.duration + 30) + + all_errors: List[str] = [] + if stderr: + all_errors.extend(self._extract_errors(stderr)) + if rc < 0: + all_errors.append(f"stress-ng rc={rc}") + + all_stressor_results: List[Dict[str, Any]] = [] + total_bogo = 0.0 + for stressor in self.stressors: + metrics = self._parse_single_stressor_metrics(stdout, stressor) + metrics["name"] = stressor + metrics["status"] = "passed" if rc == 0 else "error" + all_stressor_results.append(metrics) + total_bogo += metrics.get("bogo_ops", 0.0) + + details: Dict[str, Any] = {} + details["stressors_run"] = self.stressors + details["stressor_results"] = all_stressor_results + details["total_bogo_ops"] = round(total_bogo, 1) + details["command"] = " ".join(cmd) + details["workers_per_stressor"] = self.workers + + status = "passed" if rc == 0 else "error" + summary = f"CPU parallel stress: {details['total_bogo_ops']} bogo-ops" + if all_errors: + summary += f", {len(all_errors)} issues" + status = "failed" + + return StressResult( + component="cpu", + status=status, + summary=summary, + details=details, + errors=all_errors, + raw_output=stdout + "\n" + stderr, + ) + + @staticmethod + def _parse_single_stressor_metrics(text: str, stressor: str) -> Dict[str, Any]: + """Extract metrics for a single stressor from stress-ng output.""" + bogo_ops = 0.0 + bogo_per_sec = 0.0 + for line in text.splitlines(): + if "bogo-ops" in line.lower() and stressor in line.lower(): + m = re.search(rf"{stressor}\s+([\d.]+)\s+bogo-ops", line, re.IGNORECASE) + if m: + bogo_ops = float(m.group(1)) + m2 = re.search(r"bogo-ops/s\s+real\s+([\d.]+)", line, re.IGNORECASE) + if m2: + bogo_per_sec = float(m2.group(1)) + return { + "bogo_ops": round(bogo_ops, 1), + "bogo_ops_per_sec": round(bogo_per_sec, 3), + } + + @staticmethod + def _extract_errors(text: str) -> List[str]: + """Extract error/warning lines from stress-ng output.""" + errors = [] + for line in text.splitlines(): + lower = line.lower() + for kw in ("error", "warning", "fail", "cannot"): + if kw in lower: + errors.append(line.strip()) + break + return errors + + +# --------------------------------------------------------------------------- +# GPU Stress Tester +# --------------------------------------------------------------------------- + + +class GPUStressTester: + """ + GPU info gathering and basic stress testing. + + Capabilities (tried in order of preference): + 1. nvidia-smi — NVIDIA GPU info + monitoring + 2. glxinfo — OpenGL vendor/renderer/version + 3. glmark2 — OpenGL benchmark / stress + 4. lspci — GPU model identification fallback + """ + + def __init__(self, duration: int = 60, skip_benchmark: bool = False): + self.duration = duration + self.skip_benchmark = skip_benchmark + + # ------------------------------------------------------------------ + # Public interface + # ------------------------------------------------------------------ + + def run(self) -> StressResult: + t0 = time.monotonic() + info = self._gather_gpu_info() + stress = self._run_stress_test() + result = self._merge(info, stress) + result.duration_seconds = round(time.monotonic() - t0, 1) + return result + + def report(self, result: StressResult) -> str: + lines = [ + f"--- GPU Stress Test ---", + f" Backend: {result.backend}", + f" Status: {result.status}", + f" Duration: {result.duration_seconds}s", + f" Summary: {result.summary}", + ] + details = result.details + gpu_info = details.get("gpu_info", {}) + if gpu_info: + lines.append(" GPU Info:") + for k, v in gpu_info.items(): + lines.append(f" {k}: {v}") + if "benchmark_score" in details: + lines.append(f" Benchmark score: {details['benchmark_score']}") + if result.errors: + lines.append(" Errors:") + for e in result.errors: + lines.append(f" - {e}") + return "\n".join(lines) + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _gather_gpu_info(self) -> Dict[str, Any]: + """Collect GPU info from all available sources.""" + info: Dict[str, Any] = {} + errors: List[str] = [] + + # 1) nvidia-smi + nv_path = _find_tool("nvidia-smi") + if nv_path: + rc, stdout, stderr = _run([nv_path, "--query-gpu=name,driver_version,temperature.gpu,memory.total,utilization.gpu", "--format=csv,noheader"], timeout=15) + if rc == 0: + for line in stdout.strip().splitlines(): + parts = [p.strip() for p in line.split(",")] + if len(parts) >= 3: + info["model"] = parts[0] + info["driver_version"] = parts[1] + info["temperature_c"] = parts[2] + if len(parts) >= 4: + info["vram_total"] = parts[3] + if len(parts) >= 5: + info["utilization_pct"] = parts[4] + else: + errors.append(f"nvidia-smi: {stderr.strip()}") + else: + errors.append("nvidia-smi not found") + + # 2) glxinfo (OpenGL vendor/renderer) + glx_path = _find_tool("glxinfo") + if glx_path: + rc, stdout, stderr = _run([glx_path, "-B"], timeout=15) + if rc == 0: + for line in stdout.splitlines(): + lower = line.lower() + if "opengl vendor" in lower: + info["gl_vendor"] = line.split(":", 1)[-1].strip() + elif "opengl renderer" in lower: + info["gl_renderer"] = line.split(":", 1)[-1].strip() + elif "opengl version" in lower: + info["gl_version"] = line.split(":", 1)[-1].strip() + + # 3) lspci as fallback for model + if "model" not in info: + lspci_path = _find_tool("lspci") + if lspci_path: + rc, stdout, stderr = _run([lspci_path], timeout=10) + if rc == 0: + for line in stdout.splitlines(): + l = line.lower() + if any(kw in l for kw in ("vga", "3d controller", "display")): + info["model"] = line.strip() + break + + info["has_nvidia_gpu"] = nv_path is not None + return info + + def _run_stress_test(self) -> Dict[str, Any]: + """Run GPU benchmark/stress if available.""" + result: Dict[str, Any] = {} + errors: List[str] = [] + + if self.skip_benchmark: + result["benchmark_skipped"] = True + return result + + # Try glmark2 first (OpenGL benchmark) + glmark_path = _find_tool("glmark2") + if glmark_path: + rc, stdout, stderr = _run( + [glmark2_path, "--run-forever"], + timeout=self.duration + 15, + env={"DISPLAY": os.environ.get("DISPLAY", ":0")}, + ) + if rc == 0: + score = self._parse_glmark2_score(stdout) + result["benchmark_score"] = score + result["benchmark_tool"] = "glmark2" + else: + errors.append("glmark2 not found; no GPU benchmark available") + + if errors: + result["errors"] = errors + return result + + @staticmethod + def _parse_glmark2_score(text: str) -> Optional[int]: + """Extract the final glmark2 score. Looks for 'glmark2 Score: NNN'.""" + m = re.search(r"glmark2\s+Score:\s+(\d+)", text, re.IGNORECASE) + if m: + return int(m.group(1)) + return None + + def _merge(self, info: Dict[str, Any], stress: Dict[str, Any]) -> StressResult: + """Merge gathered info and stress results into a single StressResult.""" + all_errors = info.pop("errors", []) + all_errors.extend(stress.pop("errors", [])) + + details: Dict[str, Any] = {} + details["gpu_info"] = info + details.update(stress) + + model = info.get("model", info.get("gl_renderer", "unknown")) + summary_parts = [f"GPU: {model}"] + if "benchmark_score" in details and details["benchmark_score"] is not None: + summary_parts.append(f"glmark2 score: {details['benchmark_score']}") + if "temperature_c" in info: + summary_parts.append(f"temp: {info['temperature_c']}°C") + + status = "passed" if not all_errors else "failed" + # Even with all_errors, we always have info from lspci at minimum + backend_parts = [] + if _find_tool("nvidia-smi"): + backend_parts.append("nvidia-smi") + if _find_tool("glxinfo"): + backend_parts.append("glxinfo") + if _find_tool("glmark2"): + backend_parts.append("glmark2") + if _find_tool("lspci"): + backend_parts.append("lspci") + if not backend_parts: + backend_parts = ["none"] + + return StressResult( + component="gpu", + backend="+".join(backend_parts), + status=status, + summary="; ".join(summary_parts), + details=details, + errors=all_errors, + ) + + +# --------------------------------------------------------------------------- +# Orchestration +# --------------------------------------------------------------------------- + + +def run_all_stress_tests( + duration: int = 60, + ram: bool = True, + cpu: bool = True, + gpu: bool = True, + skip_gpu_benchmark: bool = False, +) -> Dict[str, StressResult]: + """ + Run all requested stress tests and return structured results. + + Parameters + ---------- + duration : int + Per-test duration in seconds (CPU/RAM get this; GPU benchmark runs separately). + ram, cpu, gpu : bool + Toggle individual tests. + skip_gpu_benchmark : bool + If True, only gather GPU info, skip the OpenGL benchmark loop. + + Returns + ------- + dict of {component: StressResult} + """ + results: Dict[str, StressResult] = {} + logger.info("Stress test suite starting (duration=%ds)", duration) + + if ram: + ram_tester = RAMStressTester(duration=duration) + results["ram"] = ram_tester.run() + logger.info("RAM test: %s | %s", results["ram"].status, results["ram"].summary) + + if cpu: + cpu_tester = CPUStressTester(duration=duration) + results["cpu"] = cpu_tester.run() + logger.info("CPU test: %s | %s", results["cpu"].status, results["cpu"].summary) + + if gpu: + gpu_tester = GPUStressTester(duration=duration, skip_benchmark=skip_gpu_benchmark) + results["gpu"] = gpu_tester.run() + logger.info("GPU test: %s | %s", results["gpu"].status, results["gpu"].summary) + + return results + + +def print_report(results: Dict[str, StressResult]) -> None: + """Pretty-print test results to stdout.""" + for component in ("ram", "cpu", "gpu"): + result = results.get(component) + if result is not None: + print() + print(StressResult.report.__doc__) # placeholder — will be replaced + # Actually use the testers' report methods + for component in ("ram", "cpu", "gpu"): + result = results.get(component) + if result is not None: + if component == "ram": + print(RAMStressTester().report(result)) + elif component == "cpu": + print(CPUStressTester().report(result)) + elif component == "gpu": + print(GPUStressTester().report(result)) + print() + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def parse_cli_args() -> dict: + """Simple manual arg parser (no external deps).""" + args = { + "duration": 60, + "ram": True, + "cpu": True, + "gpu": True, + "skip_gpu_benchmark": False, + "verbose": False, + } + + argv = sys.argv[1:] + i = 0 + while i < len(argv): + a = argv[i] + if a in ("--duration", "-d"): + if i + 1 < len(argv): + args["duration"] = int(argv[i + 1]) + i += 1 + elif a == "--ram-only": + args["ram"] = True + args["cpu"] = False + args["gpu"] = False + elif a == "--cpu-only": + args["ram"] = False + args["cpu"] = True + args["gpu"] = False + elif a == "--gpu-only": + args["ram"] = False + args["cpu"] = False + args["gpu"] = True + elif a in ("--no-ram",): + args["ram"] = False + elif a in ("--no-cpu",): + args["cpu"] = False + elif a in ("--no-gpu",): + args["gpu"] = False + elif a in ("--skip-gpu-benchmark",): + args["skip_gpu_benchmark"] = True + elif a in ("--verbose", "-v"): + args["verbose"] = True + elif a in ("--help", "-h"): + print("Usage: python -m src.diagnostics.stress [options]") + print() + print("Options:") + print(" -d, --duration SECS Per-test duration (default: 60)") + print(" --ram-only RAM test only") + print(" --cpu-only CPU test only") + print(" --gpu-only GPU test only") + print(" --no-ram Skip RAM test") + print(" --no-cpu Skip CPU test") + print(" --no-gpu Skip GPU test") + print(" --skip-gpu-benchmark GPU info only, no benchmark") + print(" -v, --verbose Verbose logging") + print(" -h, --help This help") + sys.exit(0) + i += 1 + return args + + +def main() -> None: + """CLI entry point.""" + config = parse_cli_args() + if config["verbose"]: + logging.basicConfig(level=logging.INFO, format="%(levelname)s | %(message)s") + else: + logging.basicConfig(level=logging.WARNING, format="%(levelname)s | %(message)s") + + print("Hermes Portable Rescue — Stress Test Suite") + print(f" Duration: {config['duration']}s per test") + print(f" RAM: {'yes' if config['ram'] else 'no'}") + print(f" CPU: {'yes' if config['cpu'] else 'no'}") + print(f" GPU: {'yes' if config['gpu'] else 'no'}") + print(f" GPU benchmark: {'no' if config['skip_gpu_benchmark'] else 'yes'}") + print() + + results = run_all_stress_tests( + duration=config["duration"], + ram=config["ram"], + cpu=config["cpu"], + gpu=config["gpu"], + skip_gpu_benchmark=config["skip_gpu_benchmark"], + ) + + print_report(results) + + # Overall status + print("=" * 50) + print("OVERALL") + for comp, res in results.items(): + print(f" {comp.upper():5s} {res.status:10s} {res.summary}") + print("=" * 50) + + +if __name__ == "__main__": + main() diff --git a/src/hermes/autorun.sh b/src/hermes/autorun.sh new file mode 100755 index 0000000..8075cb1 --- /dev/null +++ b/src/hermes/autorun.sh @@ -0,0 +1,250 @@ +#!/bin/sh +# ============================================================================== +# Hermes Portable Rescue — Auto-Launch Script (Source) +# ============================================================================== +# +# This is the master auto-launch script for the Hermes Portable Rescue USB. +# It runs when the rescue environment boots and orchestrates the entire +# diagnostic workflow. +# +# Design: +# - Detects the boot filesystem and mounts the Hermes partition +# - Creates a report directory and starts logging +# - Runs diagnostics in dependency order (inventory → analysis → fix) +# - Launches the Hermes agent for LLM-powered interpretation +# - Falls back to a diagnostic shell if the agent isn't available +# +# Environment variables (set by build/usb-image.sh or the boot environment): +# HERMES_DIR — Root of the Hermes runtime filesystem +# CONFIG — Path to config.yaml +# REPORT_DIR — Where to save diagnostic reports +# BOOT_MODE — 'live' (default), 'safe', 'recovery' +# +# ============================================================================== + +set -e + +# ─── Configuration ──────────────────────────────────────────────────────────── + +HERMES_DIR="${HERMES_DIR:-/hermes}" +CONFIG="${CONFIG:-${HERMES_DIR}/config.yaml}" +REPORT_DIR="${REPORT_DIR:-${HERMES_DIR}/reports}" +LOG_FILE="${LOG_FILE:-${REPORT_DIR}/session.log}" +BOOT_MODE="${BOOT_MODE:-live}" + +# ─── Utility Functions ──────────────────────────────────────────────────────── + +timestamp() { + date "+%Y-%m-%d %H:%M:%S" +} + +log() { + local level="$1" + shift + echo "[$(timestamp)] [${level}] $*" | tee -a "${LOG_FILE}" +} + +info() { log "INFO" "$@"; } +warn() { log "WARN" "$@" >&2; } +error() { log "ERROR" "$@" >&2; } + +# ─── Banner ──────────────────────────────────────────────────────────────────── + +print_banner() { + cat << 'BANNER' + +╔═══════════════════════════════════════════════════════════════╗ +║ ║ +║ ██╗ ██╗███████╗██████╗ ███╗ ███╗ ║ +║ ██║ ██║██╔════╝██╔══██╗████╗ ████║ ║ +║ ███████║█████╗ ██████╔╝██╔████╔██║ ║ +║ ██╔══██║██╔══╝ ██╔══██╗██║╚██╔╝██║ ║ +║ ██║ ██║███████╗██║ ██║██║ ╚═╝ ██║ ║ +║ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ║ +║ ║ +║ ┌─────────┐ ┌──────────┐ ┌──────────────────┐ ║ +║ │ SCAN │→ │ ANALYZE │→ │ RECOMMEND/FIX │ ║ +║ └─────────┘ └──────────┘ └──────────────────┘ ║ +║ ║ +║ Hermes Portable Rescue v1.0.0 ║ +║ AI-driven Windows PC diagnostic & repair toolkit ║ +║ ║ +╚═══════════════════════════════════════════════════════════════╝ +BANNER +} + +# ─── Filesystem Setup ───────────────────────────────────────────────────────── + +mount_windows_partition() { + local windows_mount="/mnt/c" + mkdir -p "${windows_mount}" 2>/dev/null || true + + # Try common Windows partitions + for device in /dev/sda1 /dev/sda2 /dev/nvme0n1p1 /dev/nvme0n1p2 /dev/mmcblk0p1; do + if [ -b "${device}" ]; then + local fstype + fstype="$(blkid -o value -s TYPE "${device}" 2>/dev/null || echo '')" + case "${fstype}" in + ntfs) + if mount -t ntfs-3g "${device}" "${windows_mount}" 2>/dev/null; then + info "Mounted Windows partition: ${device} → ${windows_mount}" + return 0 + fi + ;; + ext4|vfat) + # Not a Windows partition (or could be EFI system partition) + ;; + esac + fi + done + + warn "No Windows partition found." + return 1 +} + +# ─── Diagnostic Pipeline ────────────────────────────────────────────────────── + +run_script() { + local script="$1" + local report_name="$2" + local report_file="${REPORT_DIR}/${report_name}" + + if [ ! -f "${script}" ]; then + warn "Script not found: ${script}" + return 1 + fi + + info "Running: ${script}" + if python3 "${script}" > "${report_file}" 2>&1; then + info " ✓ ${report_name} complete (exit 0)" + return 0 + else + local rc=$? + if [ "${rc}" -eq 1 ]; then + # Component unavailable (stub) + info " ∼ ${report_name}: component unavailable" + elif [ "${rc}" -eq 2 ]; then + # Error during diagnosis + warn " ✗ ${report_name}: diagnostic error (exit ${rc})" + else + warn " ✗ ${report_name}: failed with exit code ${rc}" + fi + return ${rc} + fi +} + +run_diagnostics() { + info "Starting diagnostic pipeline..." + echo "───────────────────────────────────────────────────────" >> "${LOG_FILE}" + + # Phase 1: Hardware inventory (independent, runs first) + run_script "${HERMES_DIR}/scripts/hardware.py" "01-hardware.txt" + + # Phase 2: BSOD analysis (needs hardware inventory context) + run_script "${HERMES_DIR}/scripts/bsod.py" "02-bsod-analysis.txt" + + # Phase 3: Driver update check (needs hardware inventory) + run_script "${HERMES_DIR}/scripts/drivers.py" "03-drivers.txt" + + # Phase 4: Stress testing (manual/safe mode only) + if [ "${BOOT_MODE}" = "recovery" ]; then + info "Skipping stress tests in recovery mode." + elif grep -q '"enabled": true' "${CONFIG}" 2>/dev/null || [ -n "${RUN_STRESS_TESTS}" ]; then + run_script "${HERMES_DIR}/scripts/stress.py" "04-stress.txt" + else + info "Stress tests disabled (enable in config or set RUN_STRESS_TESTS=1)" + fi + + # Phase 5: Backup/restore (manual activation) + if [ -n "${RUN_BACKUP}" ]; then + run_script "${HERMES_DIR}/scripts/backup.py" "05-backup.txt" + fi + + echo "───────────────────────────────────────────────────────" >> "${LOG_FILE}" + info "Diagnostic pipeline complete." +} + +# ─── Agent Launch ───────────────────────────────────────────────────────────── + +launch_agent() { + info "Starting Hermes agent..." + + if [ -f "${HERMES_DIR}/agent/hermes" ]; then + exec "${HERMES_DIR}/agent/hermes" --config "${CONFIG}" + elif command -v hermes &>/dev/null; then + exec hermes --config "${CONFIG}" + else + error "Hermes agent not found on PATH or in ${HERMES_DIR}/agent/." + return 1 + fi +} + +start_diagnostic_shell() { + cat << 'SHELL' + +╔═══════════════════════════════════════════════════════════════╗ +║ DIAGNOSTIC SHELL ║ +║ Hermes agent not available — running in manual mode. ║ +╚═══════════════════════════════════════════════════════════════╝ + +Available diagnostic scripts: + + python3 /hermes/scripts/hardware.py — Hardware inventory + python3 /hermes/scripts/bsod.py — BSOD minidump analysis + python3 /hermes/scripts/drivers.py — Driver scanner + python3 /hermes/scripts/stress.py — Stress testing + python3 /hermes/scripts/backup.py — Backup/restore + +Reports saved to: /hermes/reports/ + +Type 'exit' to shut down. + +SHELL + exec /bin/sh +} + +# ─── Main ───────────────────────────────────────────────────────────────────── + +main() { + print_banner + + # Ensure report dir exists + mkdir -p "${REPORT_DIR}" + + info "Hermes Portable Rescue v1.0.0 — Boot mode: ${BOOT_MODE}" + info "Config: ${CONFIG}" + info "Reports: ${REPORT_DIR}" + info "Log: ${LOG_FILE}" + echo "" + + # Optional: mount Windows partition for access to minidumps, etc. + if [ "${MOUNT_WINDOWS}" != "no" ]; then + mount_windows_partition || true + fi + + # Run diagnostic pipeline + if [ "${SKIP_DIAGNOSTICS}" != "1" ]; then + run_diagnostics + fi + + # Print report summary + if [ -d "${REPORT_DIR}" ]; then + echo "" + info "Diagnostic reports:" + for report in "${REPORT_DIR}"/*.txt; do + if [ -f "${report}" ]; then + local rname + rname=$(basename "${report}") + echo " ${rname} ($(wc -l < "${report}") lines)" + fi + done + echo "" + fi + + # Launch agent or fall back to diagnostic shell + if ! launch_agent; then + start_diagnostic_shell + fi +} + +main "$@" diff --git a/src/hermes/config.yaml b/src/hermes/config.yaml new file mode 100644 index 0000000..c61feab --- /dev/null +++ b/src/hermes/config.yaml @@ -0,0 +1,94 @@ +# Hermes Portable Rescue — Agent Configuration +# ============================================= +# This is the **source** config for the portable Hermes agent. +# It is copied into the USB image by build/usb-image.sh. +# +# LLM strategy determined by research:llm-strat task (t_9ba3b456). +# Boot environment determined by research:boot-env task (t_163b7bb2). + +agent: + name: "Hermes Portable Rescue" + version: "1.0.0" + mode: "rescue" + description: "AI-driven Windows PC diagnostic & repair toolkit" + author: "OPLabs / Shawn" + repo: "https://gitea.ourpad.casa/shawn/hermes-portable-rescue" + +llm: + # Strategy: hybrid (local for offline, API fallback when network available) + strategy: hybrid + local_model: "/hermes/models/qwen2.5-7b-q4.gguf" + local_model_url: "https://huggingface.co/Qwen/Qwen2.5-7B-Instruct-GGUF/resolve/main/qwen2.5-7b-instruct-q4_K_M.gguf" + api_fallback: true + api_provider: openai + api_endpoint: "https://api.openai.com/v1" + model: "gpt-4o-mini" + temperature: 0.2 + max_tokens: 2048 + +diagnostics: + auto_run: true + timeout_seconds: 3600 + log_level: info + save_reports: true + report_path: "/hermes/reports/" + output_format: markdown + + modules: + hardware_inventory: + enabled: true + script: "/hermes/scripts/hardware.py" + description: "CPU, RAM, GPU, disk enumeration and health checks" + + bsod_analysis: + enabled: true + script: "/hermes/scripts/bsod.py" + description: "Windows minidump parser with LLM interpretation" + scan_paths: + - "C:/Windows/Minidump/" + - "C:/Windows/memory.dmp" + - "/mnt/c/Windows/Minidump/" + + driver_check: + enabled: true + script: "/hermes/scripts/drivers.py" + description: "Hardware-aware driver scanner and updater" + + stress_test: + enabled: false # Manual activation only — can destabilise a failing system + script: "/hermes/scripts/stress.py" + description: "RAM, CPU, and GPU stress testing orchestration" + confirm_before_run: true + + backup_restore: + enabled: false # Manual activation only + script: "/hermes/scripts/backup.py" + description: "Disk imaging and file-level backup/restore" + confirm_before_run: true + +network: + dhcp: true + fallback_static: false + dns: + - "8.8.8.8" + - "1.1.1.1" + auto_connect_wifi: false + wifi_ssid: "" + wifi_password: "" + +filesystem: + mount_windows: true + mount_point: "/mnt/c" + ntfs_driver: "ntfs-3g" + fallback_fuse: true + +display: + mode: interactive # interactive | silent | report-only + color: true + refresh_interval: 5 # seconds for live diagnostic dashboard + +logging: + file: "/hermes/reports/session.log" + level: info # debug | info | warn | error + max_size_mb: 10 + rotation: 3 diff --git a/src/hermes/launch.ps1 b/src/hermes/launch.ps1 new file mode 100644 index 0000000..a5cfbb9 --- /dev/null +++ b/src/hermes/launch.ps1 @@ -0,0 +1,39 @@ +# Hermes Portable Rescue — Launch Script (Windows / WinPE) +# Run: powershell -ExecutionPolicy Bypass -File .\launch.ps1 + +param( + [string]$HermesRoot = $PSScriptRoot +) + +$env:HERMES_ROOT = $HermesRoot +$env:HERMES_CONFIG = Join-Path $HermesRoot "config.yaml" +$env:Path = "$HermesRoot\venv\Scripts;$HermesRoot\tools;$env:Path" + +Write-Host "╔══════════════════════════════════════════════╗" -ForegroundColor Cyan +Write-Host "║ Hermes Portable Rescue — Runtime v1.0.0 ║" -ForegroundColor Cyan +Write-Host "╚══════════════════════════════════════════════╝" -ForegroundColor Cyan +Write-Host "" +Write-Host "Root: $HermesRoot" + +# Check Python availability +$python = Get-Command "python.exe" -ErrorAction SilentlyContinue +if (-not $python) { + Write-Host "[ERROR] Python not found in $HermesRoot\venv" -ForegroundColor Red + Write-Host "Run src\build-hermes-runtime.sh on a Linux system to create the venv," + Write-Host "then copy the output to this USB drive." + exit 1 +} +Write-Host "[*] Python: $($python.Path)" + +# Check for tools +Write-Host "[*] Checking tools..." +if (Test-Path "$HermesRoot\tools") { + Get-ChildItem "$HermesRoot\tools\*.exe" -ErrorAction SilentlyContinue | ForEach-Object { + Write-Host " ✓ $($_.Name)" + } +} + +# Launch agent +Write-Host "" +Write-Host "[*] Starting Hermes Agent..." +& "$HermesRoot\venv\Scripts\hermes.exe" --config "$env:HERMES_CONFIG" diff --git a/src/hermes/launch.sh b/src/hermes/launch.sh new file mode 100644 index 0000000..9ad8f83 --- /dev/null +++ b/src/hermes/launch.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# ============================================================================= +# Hermes Portable Rescue — Launch Script (Linux boot environment) +# ============================================================================= +# Source this or run directly to start the Hermes agent. +# Assumes: Python 3.11+ venv at HERMES_ROOT/venv +# ============================================================================= +set -euo pipefail + +export HERMES_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export HERMES_CONFIG="$HERMES_ROOT/config.yaml" +export PATH="$HERMES_ROOT/venv/bin:$HERMES_ROOT/tools:$PATH" + +echo "╔══════════════════════════════════════════════╗" +echo "║ Hermes Portable Rescue — Runtime v1.0.0 ║" +echo "╚══════════════════════════════════════════════╝" +echo "" +echo "Root: $HERMES_ROOT" +echo "Config: $HERMES_CONFIG" + +# --- Probe environment ------------------------------------------------------- +echo "" +echo "[*] Checking environment…" + +missing=0 +for tool in smartctl stress-ng dmidecode; do + if command -v "$tool" &>/dev/null; then + echo " ✓ $tool" + else + echo " ✗ $tool not found" + ((missing++)) + fi +done + +if [[ $missing -gt 0 && -f "$HERMES_ROOT/tools/fetch-tools.sh" ]]; then + echo "" + echo "[!] Some tools are missing. Run tools/fetch-tools.sh to download." +fi + +# --- Python check ------------------------------------------------------------ +echo "" +if [[ -f "$HERMES_ROOT/venv/bin/python3" ]]; then + PYVER=$("$HERMES_ROOT/venv/bin/python3" --version 2>&1) + echo "[✓] Python venv: $PYVER" +else + echo "[✗] Python venv not found at $HERMES_ROOT/venv" + echo " Run the build script to create it:" + echo " src/build-hermes-runtime.sh" + exit 1 +fi + +# --- Storage mount check ----------------------------------------------------- +STORAGE_MOUNT="/mnt/hermes-data" +if [[ ! -d "$STORAGE_MOUNT" ]]; then + echo "[*] Storage mount not found — reports will be saved to /tmp" +fi + +# --- Launch agent ------------------------------------------------------------ +echo "" +echo "[*] Starting Hermes Agent in rescue mode…" +echo "" + +exec "$HERMES_ROOT/venv/bin/hermes" --config "$HERMES_CONFIG" diff --git a/src/hermes/tool-manifest.json b/src/hermes/tool-manifest.json new file mode 100644 index 0000000..02ccc65 --- /dev/null +++ b/src/hermes/tool-manifest.json @@ -0,0 +1,80 @@ +{ + "schema_version": "1.0", + "generated_by": "build-hermes-runtime.sh", + "description": "Tool registry for Hermes Portable Rescue diagnostic environment", + "last_updated": null, + "tools": { + "smartctl": { + "description": "S.M.A.R.T. hard drive health check — read attributes, run self-tests", + "expected_path": "tools/smartctl", + "type": "binary", + "platforms": ["linux", "winpe"], + "test_command": "smartctl --version", + "source": "https://static.smartmontools.org/", + "install_hint": "curl -sL https://static.smartmontools.org/smartctl-x86_64 -o tools/smartctl && chmod +x tools/smartctl" + }, + "stress-ng": { + "description": "CPU, memory, and I/O stress testing", + "expected_path": "tools/stress-ng", + "type": "binary", + "platforms": ["linux"], + "test_command": "stress-ng --version", + "source": "https://github.com/ColinIanKing/stress-ng", + "install_hint": "apt-get install stress-ng || apk add stress-ng" + }, + "dmidecode": { + "description": "Hardware inventory via DMI/SMBIOS — CPU, RAM, motherboard", + "expected_path": "/usr/sbin/dmidecode", + "type": "system_package", + "platforms": ["linux"], + "test_command": "dmidecode --version", + "install_hint": "apt-get install dmidecode || apk add dmidecode" + }, + "lshw": { + "description": "Full hardware listing — PCI, USB, disks, network", + "expected_path": "/usr/bin/lshw", + "type": "system_package", + "platforms": ["linux"], + "test_command": "lshw -version", + "install_hint": "apt-get install lshw || apk add lshw" + }, + "memtester": { + "description": "Userspace memory tester — catch RAM errors without reboot", + "expected_path": "tools/memtester", + "type": "binary", + "platforms": ["linux", "winpe"], + "test_command": "memtester --version", + "source": "https://pyropus.ca/software/memtester/", + "install_hint": "apt-get install memtester || apk add memtester" + }, + "ntfs-3g": { + "description": "NTFS read/write driver for accessing Windows partitions", + "expected_path": "/usr/bin/ntfs-3g", + "type": "system_package", + "platforms": ["linux"], + "test_command": "ntfs-3g --version", + "install_hint": "apt-get install ntfs-3g || apk add ntfs-3g" + }, + "ddrescue": { + "description": "Disk imaging with error recovery — salvage data from failing drives", + "expected_path": "/usr/bin/ddrescue", + "type": "system_package", + "platforms": ["linux"], + "test_command": "ddrescue --version", + "install_hint": "apt-get install gddrescue || apk add ddrescue" + }, + "parted": { + "description": "Partition table manipulation", + "expected_path": "/sbin/parted", + "type": "system_package", + "platforms": ["linux"], + "test_command": "parted --version", + "install_hint": "apt-get install parted || apk add parted" + } + }, + "groups": { + "must_have": ["smartctl", "dmidecode", "lshw", "memtester"], + "stress": ["stress-ng"], + "storage": ["ntfs-3g", "ddrescue", "parted"] + } +}