build:usb-image — Final USB image assembly script, Ventoy config, and Hermes agent runtime structure

- build/usb-image.sh: Main USB image assembler with artifact validation,
  ISO building, disk image creation, and USB write support
- build/ventoy.json: Ventoy configuration for multi-boot USB
- src/hermes/config.yaml: Portable Hermes agent configuration
- src/hermes/autorun.sh: Auto-launch script with full diagnostic pipeline
- Makefile: Build system with iso/image/check/clean/write targets

Generates fallback stubs for any diagnostic modules whose upstream
build tasks haven't completed yet, ensuring the image boots even
during active development.
This commit is contained in:
2026-07-04 03:53:30 -04:00
parent 8949e67783
commit 1479e161ab
15 changed files with 4013 additions and 0 deletions
+135
View File
@@ -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 <target> [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"
+804
View File
@@ -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'
<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend">
<settings pass="windowsPE">
<component name="Microsoft-Windows-Setup"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS">
<UserData>
<ProductKey>
<WillShowUI>Never</WillShowUI>
</ProductKey>
</UserData>
</component>
</settings>
</unattend>
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 "$@"
+25
View File
@@ -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" }
]
}
}
+578
View File
@@ -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
```
+339
View File
@@ -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 ""
+28
View File
@@ -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",
]
Binary file not shown.
Binary file not shown.
+583
View File
@@ -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)
+995
View File
@@ -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 <n> --vm-bytes <bytes> (user-space RAM stress)
2. memtester <bytes> <iterations> (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 <workers> --vm-bytes <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 <vm_bytes> <iterations>."""
# 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}[^>]*>(.*?)</{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()
+250
View File
@@ -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 "$@"
+94
View File
@@ -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
+39
View File
@@ -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"
+63
View File
@@ -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"
+80
View File
@@ -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"]
}
}