Files
hermes-agent/hermes_platform/host/facts.py
teknium1 b412ee9206 fix(platform): an unreadable GPU classifies as unknown, so requires.gpu fails open as documented
When every DRM card's vendor file or every Windows display adapter's
MatchingDeviceId was unreadable, classify_gpu_vendors returned 'none' and
availability refused with unsupported_gpu, contradicting the documented
'unreadable GPU passes' rule. Windows now records an unreadable numbered
adapter as an empty id so the classifier sees it.

Also drops the type-narrowing assert in availability() and makes
_action's gpu_label required.
2026-10-03 10:26:12 -07:00

330 lines
12 KiB
Python

"""Cached OS, architecture, CPU, memory and GPU facts for the current machine.
Native architecture remains accurate when the Python process is emulated.
"""
from __future__ import annotations
import functools
import os
import platform
import sys
_ARCH_NAMES = {
"amd64": "amd64", "x86_64": "amd64", "x64": "amd64",
"arm64": "arm64", "aarch64": "arm64",
"x86": "x86", "i386": "x86", "i686": "x86",
}
IMAGE_FILE_MACHINE = {0x8664: "amd64", 0xAA64: "arm64", 0x014C: "x86"}
def normalize_arch(raw: str | None) -> str:
"""Return a canonical architecture name."""
return _ARCH_NAMES.get((raw or "").strip().lower(), "unknown")
@functools.cache
def os_family() -> str:
"""Return the current platform identifier."""
return sys.platform
@functools.cache
def process_arch() -> str:
"""Return the architecture of this Python process."""
return normalize_arch(platform.machine())
def windows_native_arch(*, wow64_native: int | None, machine: str, env_arch: str | None) -> str:
"""Return the native architecture from Windows observations.
Order matters: ``PROCESSOR_ARCHITECTURE`` reads ``AMD64`` inside an x64-emulated process
on ARM64 hardware, so the environment is consulted only when both APIs are unavailable.
"""
name = IMAGE_FILE_MACHINE.get(wow64_native or 0)
if name:
return name
normalized = normalize_arch(machine)
if normalized != "unknown":
return normalized
return normalize_arch(env_arch)
def _wow64_native_machine() -> int | None:
"""Return the IsWow64Process2 native machine, or ``None`` when unavailable."""
import ctypes
from ctypes import wintypes
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
probe = getattr(kernel32, "IsWow64Process2", None)
if probe is None:
return None
# ctypes otherwise truncates the HANDLE pseudo-handle to c_int.
kernel32.GetCurrentProcess.restype = wintypes.HANDLE
kernel32.GetCurrentProcess.argtypes = []
probe.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.USHORT), ctypes.POINTER(wintypes.USHORT)]
probe.restype = wintypes.BOOL
process_machine = wintypes.USHORT(0)
native_machine = wintypes.USHORT(0)
if not probe(kernel32.GetCurrentProcess(), ctypes.byref(process_machine), ctypes.byref(native_machine)):
return None
return native_machine.value or None
def _darwin_translated() -> bool:
"""Return whether this process runs under Rosetta."""
return _sysctl_int(b"sysctl.proc_translated") == 1
@functools.cache
def native_arch() -> str:
"""Return the machine architecture even when this process is emulated."""
if sys.platform == "win32":
try:
wow64 = _wow64_native_machine()
except (OSError, AttributeError, TypeError, ValueError):
wow64 = None
env_arch = os.environ.get("PROCESSOR_ARCHITEW6432") or os.environ.get("PROCESSOR_ARCHITECTURE")
return windows_native_arch(wow64_native=wow64, machine=platform.machine(), env_arch=env_arch)
if sys.platform == "darwin" and process_arch() == "amd64" and _darwin_translated():
return "arm64"
return process_arch()
_CPU_KEY = r"HARDWARE\DESCRIPTION\System\CentralProcessor\0"
def parse_cpuinfo(text: str, *, device_tree_model: str = "") -> str:
"""Return the first available CPU or device-tree model."""
for key in ("model name", "Hardware"):
for line in text.splitlines():
if line.lower().startswith(key.lower()) and ":" in line:
return line.split(":", 1)[1].strip()
return device_tree_model.replace("\0", "").replace("_", " ").strip()
def _read_text(path: str, limit: int = 65536) -> str:
try:
with open(path, "r", encoding="utf-8", errors="replace") as handle:
return handle.read(limit)
except OSError:
return ""
def _winreg_str(subkey: str, name: str) -> str:
import winreg
try:
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, subkey) as key:
value = winreg.QueryValueEx(key, name)[0]
except OSError:
return ""
return str(value).strip()
def _sysctl_int(name: bytes) -> int | None:
import ctypes
import ctypes.util
libc = ctypes.CDLL(ctypes.util.find_library("c"))
value = ctypes.c_int(0)
size = ctypes.c_size_t(ctypes.sizeof(ctypes.c_int))
if libc.sysctlbyname(name, ctypes.byref(value), ctypes.byref(size), None, 0) != 0:
return None
return value.value
def _sysctl_u64(name: bytes) -> int | None:
import ctypes
import ctypes.util
libc = ctypes.CDLL(ctypes.util.find_library("c"))
value = ctypes.c_uint64(0)
size = ctypes.c_size_t(ctypes.sizeof(ctypes.c_uint64))
if libc.sysctlbyname(name, ctypes.byref(value), ctypes.byref(size), None, 0) != 0:
return None
return value.value
def _sysctl_str(name: bytes) -> str:
import ctypes
import ctypes.util
libc = ctypes.CDLL(ctypes.util.find_library("c"))
size = ctypes.c_size_t(0)
if libc.sysctlbyname(name, None, ctypes.byref(size), None, 0) != 0:
return ""
buffer = ctypes.create_string_buffer(size.value)
if libc.sysctlbyname(name, buffer, ctypes.byref(size), None, 0) != 0:
return ""
return buffer.value.decode(errors="replace").strip()
@functools.cache
def cpu_model() -> str:
"""Return the CPU model, or an empty string when unavailable."""
if sys.platform == "win32":
return _winreg_str(_CPU_KEY, "ProcessorNameString")
if sys.platform == "darwin":
return _sysctl_str(b"machdep.cpu.brand_string")
return parse_cpuinfo(_read_text("/proc/cpuinfo"), device_tree_model=_read_text("/proc/device-tree/model", 512))
@functools.cache
def cpu_vendor() -> str:
"""Return the CPU vendor, or an empty string when unavailable."""
if sys.platform == "win32":
return _winreg_str(_CPU_KEY, "VendorIdentifier")
if sys.platform.startswith("linux"):
for line in _read_text("/proc/cpuinfo").splitlines():
if line.lower().startswith("vendor_id") and ":" in line:
return line.split(":", 1)[1].strip()
return ""
def parse_meminfo_total(text: str) -> int | None:
"""Return ``MemTotal`` from ``/proc/meminfo`` text, in bytes."""
for line in text.splitlines():
if line.startswith("MemTotal:"):
fields = line.split()
if len(fields) >= 2 and fields[1].isdigit():
return int(fields[1]) * 1024
return None
def _windows_ram_total() -> int | None:
import ctypes
from ctypes import wintypes
class MEMORYSTATUSEX(ctypes.Structure):
_fields_ = [
("dwLength", wintypes.DWORD), ("dwMemoryLoad", wintypes.DWORD),
("ullTotalPhys", ctypes.c_uint64), ("ullAvailPhys", ctypes.c_uint64),
("ullTotalPageFile", ctypes.c_uint64), ("ullAvailPageFile", ctypes.c_uint64),
("ullTotalVirtual", ctypes.c_uint64), ("ullAvailVirtual", ctypes.c_uint64),
("ullAvailExtendedVirtual", ctypes.c_uint64),
]
status = MEMORYSTATUSEX()
status.dwLength = ctypes.sizeof(MEMORYSTATUSEX)
if not ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(status)):
return None
return int(status.ullTotalPhys) or None
@functools.cache
def ram_total_bytes() -> int | None:
"""Return the physical memory visible to this OS, or ``None`` when unavailable."""
try:
if sys.platform == "win32":
return _windows_ram_total()
if sys.platform == "darwin":
return _sysctl_u64(b"hw.memsize") or None
except (AttributeError, OSError, TypeError, ValueError):
return None
return parse_meminfo_total(_read_text("/proc/meminfo", 4096))
# PCI vendor ids; a discrete vendor wins over an integrated one on hybrid machines.
_GPU_PCI_VENDORS = {"10de": "nvidia", "1002": "amd", "8086": "intel"}
_GPU_PRIORITY = ("nvidia", "amd", "intel")
_DISPLAY_CLASS_KEY = r"SYSTEM\CurrentControlSet\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}"
_GPU_SCAN_LIMIT = 16
def classify_gpu_vendors(vendor_ids: list[str]) -> str:
"""Return the highest-priority GPU class for PCI vendor ids, or ``none``.
``unknown`` when devices were seen but none of their vendor ids could be read.
"""
if vendor_ids and not any(v.strip() for v in vendor_ids):
return "unknown"
found = {_GPU_PCI_VENDORS.get(v.strip().lower().removeprefix("0x")) for v in vendor_ids}
return next((name for name in _GPU_PRIORITY if name in found), "none")
def _linux_gpu_vendor_ids() -> list[str] | None:
"""PCI vendor ids of DRM cards; ``None`` when sysfs exposes no DRM tree at all."""
root = "/sys/class/drm"
try:
entries = sorted(os.listdir(root))[:_GPU_SCAN_LIMIT * 4]
except OSError:
return None
cards = [e for e in entries if e.startswith("card") and "-" not in e][:_GPU_SCAN_LIMIT]
return [_read_text(f"{root}/{card}/device/vendor", 16).strip() for card in cards]
def _windows_gpu_vendor_ids() -> list[str] | None:
import winreg
vendors = []
try:
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, _DISPLAY_CLASS_KEY) as root:
for index in range(_GPU_SCAN_LIMIT):
try:
subkey = winreg.EnumKey(root, index)
except OSError:
break
device_id = _winreg_str(f"{_DISPLAY_CLASS_KEY}\\{subkey}", "MatchingDeviceId").upper()
if "VEN_" in device_id:
vendors.append(device_id.split("VEN_", 1)[1][:4])
elif subkey.isdigit(): # an adapter whose id could not be read
vendors.append("")
except OSError:
return None
return vendors
@functools.cache
def gpu_class() -> str:
"""Return ``nvidia``/``amd``/``intel``/``apple_silicon``/``none``, or ``unknown``.
Reads sysfs, the registry or sysctl only: never a subprocess or a driver library.
"""
try:
if sys.platform == "darwin":
return "apple_silicon" if native_arch() == "arm64" else "unknown"
if sys.platform == "win32":
vendors = _windows_gpu_vendor_ids()
return "unknown" if vendors is None else classify_gpu_vendors(vendors)
except (AttributeError, OSError, TypeError, ValueError):
return "unknown"
if os.path.exists("/proc/driver/nvidia/version"):
return "nvidia"
vendors = _linux_gpu_vendor_ids()
return "unknown" if vendors is None else classify_gpu_vendors(vendors)
def _windows_interactive_session() -> bool:
import ctypes
from ctypes import wintypes
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
process_id = kernel32.GetCurrentProcessId()
session_id = wintypes.DWORD()
if not kernel32.ProcessIdToSessionId(process_id, ctypes.byref(session_id)) or session_id.value == 0:
return False
active_session = kernel32.WTSGetActiveConsoleSessionId()
if active_session != 0xFFFFFFFF and active_session == session_id.value:
return True
return bool(kernel32.GetProcessWindowStation())
def interactive_session() -> bool:
"""Return whether this process can reach an interactive user session."""
if sys.platform == "win32":
try:
return _windows_interactive_session()
except (AttributeError, OSError, TypeError, ValueError):
return False
if sys.platform.startswith("linux"):
session_id = _read_text("/proc/self/sessionid", 64).strip()
return bool(session_id and session_id != "4294967295" and os.path.isdir(f"/run/user/{os.getuid()}"))
return True
def clear_caches() -> None:
"""Clear every cached host fact."""
for fact in (os_family, process_arch, native_arch, cpu_model, cpu_vendor, ram_total_bytes, gpu_class):
fact.cache_clear()