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 0000000..c7e387c Binary files /dev/null and b/src/diagnostics/__pycache__/__init__.cpython-311.pyc differ diff --git a/src/diagnostics/__pycache__/hardware.cpython-311.pyc b/src/diagnostics/__pycache__/hardware.cpython-311.pyc new file mode 100644 index 0000000..fd8bbbe Binary files /dev/null and b/src/diagnostics/__pycache__/hardware.cpython-311.pyc differ diff --git a/src/diagnostics/hardware.py b/src/diagnostics/hardware.py new file mode 100644 index 0000000..50a69f5 --- /dev/null +++ b/src/diagnostics/hardware.py @@ -0,0 +1,583 @@ +""" +Hermes Portable Rescue — Hardware Inventory & Health Checks. + +Enumerates and assesses CPU, RAM, GPU, and disk subsystems from a +Linux-based rescue environment. Wraps CLI tools (dmidecode, lshw, +smartctl, lscpu) and falls back to /sys and /proc where possible. + +Typical usage:: + + from diagnostics.hardware import HardwareInventory + + hw = HardwareInventory(log_warnings=True) + report = hw.run() # dict, JSON-serialisable + print(hw.text_report()) # human-readable summary +""" + +from __future__ import annotations + +import json +import logging +import os +import re +import subprocess +import sys +from dataclasses import dataclass, field, fields, is_dataclass +from pathlib import Path +from typing import Any, Optional + +logger = logging.getLogger(__name__) + + +def _asdict(obj) -> 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"] + } +}